Skip to content

Angular review: signal safety, native directive, type guards, lifecycle fixes#129

Closed
sonukapoor wants to merge 7 commits into
open-circle:feat/angular-packagefrom
sonukapoor:feat/angular-package
Closed

Angular review: signal safety, native directive, type guards, lifecycle fixes#129
sonukapoor wants to merge 7 commits into
open-circle:feat/angular-packagefrom
sonukapoor:feat/angular-package

Conversation

@sonukapoor

@sonukapoor sonukapoor commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

This PR applies the Angular-nativeness review requested by Fabian on PR #124. All changes are on top of the existing feat/angular-package branch with no modifications to any other code.

Seven commits covering the issues found during the review.

readSignalOrValue — signal detection bug

typeof value === 'function' incorrectly identifies any function as a signal. Replaced with isSignal() from @angular/core, which uses Angular's internal signal symbol marker and is available since Angular 17.

props object and ref callback — React patterns removed

The props sub-object (field.props.name, field.props.onFocus, etc.) and the ref callback are React concepts with no equivalent in Angular templates. Removed both.

name, autofocus, onFocus, onChange, and onBlur are now flat on FieldStore directly. The ref callback is replaced by registerElement(element): () => void which returns a precise per-element cleanup function.

Added FormischFieldElementDirective ([formischFieldElement]), the Angular-native integration point equivalent to [formControl] or Angular CDK directives. Applied to any focusable element inside a formisch-field template, it sets [attr.name] and wires (focus), (input), (change), and (blur) via host bindings — no manual event wiring needed:

<input [formischFieldElement]="field" [value]="field.input()" />

For custom wrapper components the flat API is used directly without a props. prefix.

validate: 'initial' — missing test

Added a test that renders a real component, triggers the render cycle, and asserts that isValid() becomes false after initial validation runs via afterNextRender.

FormischForm class forwarding — runs once, uses querySelector

Replaced querySelector with viewChild.required for a typed direct reference to the inner <form>. Split the work into two lifecycle hooks: afterNextRender with write phase for the one-time element registration, and afterRenderEffect with earlyRead/write phases for class forwarding so it re-runs reactively after every render cycle. Added a test that verifies classes are moved from the host to the inner form element.

ngTemplateContextGuard — typed let-field context

Without a guard, let-field and let-fieldArray in ng-template blocks are typed as any. Added static ngTemplateContextGuard to both FormischField and FormischFieldArray with exported FormischFieldContext and FormischFieldArrayContext interfaces. Users now get full IDE autocompletion on field.input(), field.errors(), fieldArray.items(), etc.

AppComponent — subscription leak, setTimeout, location.pathname, @HostListener

  • router.events.subscribe was never unsubscribed. Piped through takeUntilDestroyed() from @angular/core/rxjs-interop.
  • setTimeout replaced with afterNextRender({ read }, { injector }) — the Angular-native way to schedule a DOM read after the next render cycle.
  • Global location.pathname replaced with Angular's Location service from @angular/common.
  • @HostListener replaced with host property binding on @Component (the Angular style guide marks @HostListener as legacy).

Summary by cubic

Makes the Angular package feel native and type-safe. Replaces React-style field props/ref with a flat API, adds the [formischFieldElement] directive, fixes signal detection, and improves <formisch-form> lifecycle behavior.

  • Migration

    • Replace field.props.namefield.name, field.props.autofocusfield.autofocus.
    • Replace field.props.onFocus/onChange/onBlurfield.onFocus/onChange/onBlur.
    • Use [formischFieldElement] on native inputs for name + event wiring and element registration, e.g. <input [formischFieldElement]="field" [value]="field.input()" />.
    • For custom wrappers, bind name and events from the flat API; if needed programmatically, call field.registerElement(el) and use the returned cleanup.
    • Import FormischFieldElementDirective (re-exported from the Angular entrypoint) where used.
  • Bug Fixes

    • Use isSignal from @angular/core in readSignalOrValue to prevent treating plain functions as signals.
    • Run initial validation after the first render when validate: 'initial' using phased afterNextRender.
    • <formisch-form> now forwards host classes to the inner <form> on every render via afterRenderEffect, and registers the form with viewChild.required.
    • Playground: fix router subscription leak, replace setTimeout with afterNextRender, use Location from @angular/common, and move @HostListener to host bindings.

Written for commit 381e1be. Summary will update on new commits.

Review in cubic

The props object and ref callback were React concepts with no equivalent
in Angular templates.

Remove FieldElementProps and the props sub-object from FieldStore. Move
name, autofocus, onFocus, onChange, and onBlur directly onto FieldStore
so Angular developers access field state without a props namespace wrapper.

Remove the ref callback. Angular templates have no mechanism to call a ref
callback on a native element, so the internal elements array was never
populated and focus management silently did nothing.

Add registerElement(element): () => void to FieldStore as the replacement
registration API. It pushes the element into the internal store and returns
a per-element cleanup function, replacing the coarse isConnected filter that
ran on component destroy.

Add FormischFieldElementDirective ([formischFieldElement]). This is the
Angular-native integration point, equivalent to [formControl] or Angular
CDK directives like [cdkDrag]. Applied to any focusable element inside a
formisch-field template, it:

- Sets [attr.name] automatically via a host binding
- Delegates (focus), (input), (change), and (blur) DOM events to
  field.onFocus, field.onChange, and field.onBlur via host bindings,
  covering both text inputs (input event) and checkboxes/selects
  (change event) without any manual template wiring
- Registers the element via registerElement in the afterNextRender write
  phase and cleans up precisely via DestroyRef when destroyed

With the directive a bare input only needs value bound explicitly:

  <input [formischFieldElement]="field" [value]="field.input()" />

For custom wrapper components the flat API is used directly:

  [name]="field.name"
  (fieldFocus)="field.onFocus($event)"
  (fieldChange)="field.onChange($event)"
  (fieldBlur)="field.onBlur($event)"

Update all playground pages, tests, and type tests to use the flat API.
Two issues existed in the previous implementation:

The inner <form> element was located via querySelector on the host, which
is fragile and bypasses Angular's view query system. Replace this with a
viewChild.required reference using a #formEl template variable so the
reference is typed, direct, and managed by Angular.

Class forwarding ran only once inside afterNextRender. If a parent bound
classes dynamically to <formisch-form> after the initial render, those
changes were never picked up. Replace the class-forwarding portion with
afterRenderEffect using the earlyRead/write phase split:

- earlyRead reads the host element's className from the DOM without
  touching it, returning it as the phase result.
- write receives that result as a Signal<string>, reads it, and if
  non-empty applies it to the inner <form> element and clears the host
  attribute. Using a dedicated write phase prevents layout thrashing by
  keeping DOM reads and writes in separate phases.

afterRenderEffect runs after every render cycle, so class changes applied
by Angular's change detection are always forwarded. Once the host class is
cleared the write phase becomes a no-op on subsequent renders unless the
parent re-binds a class.

Element registration (writing the form element reference into the internal
store) remains in afterNextRender with a write phase since the element
reference never changes after the first render.

Capture hostEl, formEl, and ofSignal as local constants before the
callbacks to avoid this references inside effect closures, consistent with
the pattern used in FormischFieldElementDirective.

Add a dedicated FormischForm class forwarding describe block to the test
suite that verifies classes are moved from the host to the inner form
element and removed from the host after render.
Without a type guard, the let-field and let-fieldArray variables in
ng-template blocks are typed as any by Angular's template type checker.
This means users get no autocompletion on field.input(), field.errors(),
fieldArray.items(), or any other FieldStore and FieldArrayStore members.

Add a static ngTemplateContextGuard method to both components. Angular's
template type checker calls this method to narrow the $implicit context
type when a consumer writes let-field or let-fieldArray on an ng-template.
The guard propagates the component's TSchema and TFieldPath/TFieldArrayPath
generics into the template variable type, so the IDE can provide accurate
completions and type errors for the full FieldStore and FieldArrayStore
surfaces.

Export FormischFieldContext and FormischFieldArrayContext interfaces
alongside the components so consumers can reference the context type
directly if needed (e.g. when manually typing a TemplateRef).
Three Angular-nativeness issues existed in the root app component.

The router.events subscription was never unsubscribed, leaking memory
whenever the component is destroyed (e.g. in tests or SSR). Pipe it
through takeUntilDestroyed() from @angular/core/rxjs-interop, which
automatically unsubscribes when the component's DestroyRef fires. Also
add a filter() operator to narrow the event type to NavigationEnd so the
subscribe callback is typed and the instanceof check is gone.

setTimeout was used to defer the DOM read after navigation so that
Angular could finish updating routerLinkActive state before reading
offsetLeft and offsetWidth. Replace it with afterNextRender({ read },
{ injector }) which is the Angular-native equivalent: it schedules the
callback in the read phase after Angular has completed its next render
cycle, using the component's injector so the ref is scoped correctly.

location.pathname referenced the global browser Location object directly,
which would throw in SSR and bypasses Angular's routing abstractions.
Replace it with Angular's Location service from @angular/common injected
via inject(Location), using this.location.path() to get the current path.

Also replace the @HostListener decorator on onResize with a host property
binding on the @component decorator. The Angular style guide marks
@HostListener as legacy and recommends the host object for new code.
Add FormischFieldElementDirective.test.ts covering the four host bindings:
name attribute is set from the field path, focus event marks the field as
touched, input event updates the field value, and change event also updates
the field value. All other exported APIs have runtime tests; this fills the
gap for the new directive.

Switch the validate-initial path in injectForm from the plain afterNextRender
callback to the phased write form, consistent with every other afterNextRender
call in the package.
@vercel

vercel Bot commented Jun 3, 2026

Copy link
Copy Markdown

@sonukapoor is attempting to deploy a commit to the Open Circle Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Jun 3, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0b73ad6a-27ea-4f86-a4c5-194281932abd

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@dosubot dosubot Bot added size:XL This PR changes 500-999 lines, ignoring generated files. fix Smaller bug fix or improvement labels Jun 3, 2026
@sonukapoor

Copy link
Copy Markdown
Contributor Author

Closing — the upstream branch has been significantly updated since this was opened. Will rebase and re-evaluate which changes are still relevant.

@sonukapoor sonukapoor closed this Jun 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fix Smaller bug fix or improvement size:XL This PR changes 500-999 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant