From fd4a51dfbc78be20b4f7e60f3ea8b7ceeafec190 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Tue, 19 May 2026 00:08:56 +0200 Subject: [PATCH 01/17] feat(angular): detect model() signal output in type inference (Layer A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Layer A native detection of Angular model() signal outputs in the @storybook/angular public type inference. Refs https://github.com/storybookjs/storybook/issues/34831 - Adds AngularModelSignal / AngularHasModelSignal / ModelSignal aliases mirroring the existing InputSignal/OutputEmitterRef conditional style. - Adds TransformModelSignalType: maps each ModelSignal field to E and synthesizes an intersection member `${prop}Change`: (e: E) => void (the xChange member is compiler-synthesized, never a real keyof T member). - Pins TransformComponentType composition with TransformModelSignalType as the INNERMOST wrapper (do not reorder): the synthesized `${prop}Change` is (e:E)=>void so it passes the outer Input/Output/Event transforms unchanged, and since ModelSignal extends InputSignal the model value field is idempotently re-collapsed by the outer TransformInputSignalType (no double-transform divergence). - Adds public-types.test-d.ts asserting the FINAL composed TransformComponentType (composed, NOT TransformModelSignalType in isolation) for color/colorChange + model.required() + full no-regression coverage (input(), transform input(), output(), EventEmitter, @Input, @Output) resolving simultaneously in one type. Documented limitation (for the AC-X3 changelog): aliased model(prop, { alias: 'a' }) produces aChange at runtime, but Layer A can only synthesize ${propName}Change because TypeScript cannot observe the runtime alias. Runtime detection (Layer C) handles the alias via the resolved binding name on ɵcmp. model.required() is fully covered. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../angular/src/client/public-types.test-d.ts | 77 +++++++++++++++++++ .../angular/src/client/public-types.ts | 45 ++++++++++- 2 files changed, 120 insertions(+), 2 deletions(-) create mode 100644 code/frameworks/angular/src/client/public-types.test-d.ts diff --git a/code/frameworks/angular/src/client/public-types.test-d.ts b/code/frameworks/angular/src/client/public-types.test-d.ts new file mode 100644 index 000000000000..666fdd14637b --- /dev/null +++ b/code/frameworks/angular/src/client/public-types.test-d.ts @@ -0,0 +1,77 @@ +import { describe, expectTypeOf, it } from 'vitest'; + +import { EventEmitter, Input, Output, input, model, numberAttribute, output } from '@angular/core'; + +import type { TransformComponentType } from './public-types.ts'; + +/** + * Layer A (type inference) regression suite for Angular `model()` signal + * outputs. + * + * Assertions are made on the FINAL composed `TransformComponentType` (NOT + * `TransformModelSignalType` in isolation), proving the pinned innermost + * composition resolves the synthesized `${prop}Change` key and the model value + * field SIMULTANEOUSLY in one composed type, with zero regression to the + * existing `input()` / `output()` / `EventEmitter` / `@Input` / `@Output` + * channels. + * + * Known limitation (also recorded for the AC-X3 changelog): aliased + * `model(prop, { alias: 'a' })` produces `aChange` at runtime, but Layer A can + * only synthesize `${propName}Change`. Runtime detection (Layer C) handles the + * alias correctly via the resolved binding name on `ɵcmp`. + */ +class C { + color = model(); + reqd = model.required(); + plain = input(); + withT = input(0, { transform: numberAttribute }); + evt = output(); + ee = new EventEmitter(); + @Input() decIn!: string; + @Output() decOut = new EventEmitter(); +} + +type Transformed = TransformComponentType; + +describe('TransformComponentType — model() signal outputs (Layer A)', () => { + it('maps a model() field to its value type and synthesizes ${prop}Change', () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf<(e: string) => void>(); + }); + + it('covers model.required() identically to model()', () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf<(e: boolean) => void>(); + }); + + it('does not regress input() signal inputs', () => { + expectTypeOf().toEqualTypeOf(); + }); + + it('does not regress transform input() signal inputs', () => { + // Pre-existing, unchanged behavior of `TransformInputSignalType`: it + // extracts the WRITE/transform-input type `U` from + // `InputSignalWithTransform`. `numberAttribute` has signature + // `(value: unknown) => number`, so the signal is + // `InputSignalWithTransform` and the transform surfaces + // `unknown` (the accepted bound input). Layer A does not alter this; this + // assertion pins the no-regression baseline. + expectTypeOf().toEqualTypeOf(); + }); + + it('does not regress output() signal outputs', () => { + expectTypeOf().toEqualTypeOf<(e: string) => void>(); + }); + + it('does not regress EventEmitter outputs', () => { + expectTypeOf().toEqualTypeOf<(e: number) => void>(); + }); + + it('does not regress @Input decorator inputs', () => { + expectTypeOf().toEqualTypeOf(); + }); + + it('does not regress @Output decorator outputs', () => { + expectTypeOf().toEqualTypeOf<(e: void) => void>(); + }); +}); diff --git a/code/frameworks/angular/src/client/public-types.ts b/code/frameworks/angular/src/client/public-types.ts index 4b5b3f3ab846..e993e7a739c8 100644 --- a/code/frameworks/angular/src/client/public-types.ts +++ b/code/frameworks/angular/src/client/public-types.ts @@ -52,9 +52,21 @@ export type Loader = LoaderFunction; export type StoryContext = GenericStoryContext; export type Preview = ProjectAnnotations; -/** Utility type that transforms InputSignal and EventEmitter types */ +/** + * Utility type that transforms InputSignal, ModelSignal, OutputEmitterRef and + * EventEmitter types. + * + * Composition is pinned (do NOT reorder): `TransformModelSignalType` is the + * INNERMOST wrapper so that (a) the synthesized `${K}Change` key is created + * before the outer transforms run and passes through them unchanged (it is + * `(e: E) => void`, which matches none of the Input/Output/Event extends + * clauses), and (b) since `ModelSignal extends InputSignal`, the model's + * value field — after `TransformModelSignalType` maps it to `E` — is + * idempotently re-collapsed by the outer `TransformInputSignalType` to the same + * `E` (no double-transform divergence). + */ export type TransformComponentType = TransformInputSignalType< - TransformOutputSignalType> + TransformOutputSignalType>> >; // @ts-ignore Angular < 17.2 doesn't export InputSignal @@ -63,15 +75,19 @@ type AngularInputSignal = AngularCore.InputSignal; type AngularInputSignalWithTransform = AngularCore.InputSignalWithTransform; // @ts-ignore Angular < 17.3 doesn't export AngularOutputEmitterRef type AngularOutputEmitterRef = AngularCore.OutputEmitterRef; +// @ts-ignore Angular < 17.2 doesn't export ModelSignal +type AngularModelSignal = AngularCore.ModelSignal; type AngularHasInputSignal = typeof AngularCore extends { input: infer U } ? true : false; type AngularHasOutputSignal = typeof AngularCore extends { output: infer U } ? true : false; +type AngularHasModelSignal = typeof AngularCore extends { model: infer U } ? true : false; type InputSignal = AngularHasInputSignal extends true ? AngularInputSignal : never; type InputSignalWithTransform = AngularHasInputSignal extends true ? AngularInputSignalWithTransform : never; type OutputEmitterRef = AngularHasOutputSignal extends true ? AngularOutputEmitterRef : never; +type ModelSignal = AngularHasModelSignal extends true ? AngularModelSignal : never; type TransformInputSignalType = { [K in keyof T]: T[K] extends InputSignal @@ -85,6 +101,31 @@ type TransformOutputSignalType = { [K in keyof T]: T[K] extends OutputEmitterRef ? (e: E) => void : T[K]; }; +/** + * Angular `model()` generates a binding pair: an input `x: T` plus a + * compiler-synthesized output `xChange: (e: T) => void`. The `xChange` member is + * NOT a real class member, so it can never be discovered by iterating + * `keyof T`; it must be synthesized here. + * + * This type maps every `ModelSignal` field `K` to its value type `E`, and + * additionally synthesizes an intersection member keyed `` `${K}Change` `` typed + * `(e: E) => void`. + * + * Known limitation (documented for the AC-X3 changelog): aliased + * `model(prop, { alias: 'a' })` produces `aChange` at runtime, but the type + * layer can only synthesize `${propName}Change` (`propChange`) because + * TypeScript cannot observe the runtime alias. Runtime detection (Layer C, via + * the resolved binding name on `ɵcmp`) still handles aliasing correctly. + * `model.required()` has the same shape as `model()` and is fully covered. + */ +type TransformModelSignalType = { + [K in keyof T]: T[K] extends ModelSignal ? E : T[K]; +} & { + [K in keyof T as T[K] extends ModelSignal + ? `${K & string}Change` + : never]: T[K] extends ModelSignal ? (e: E) => void : never; +}; + type TransformEventType = { [K in keyof T]: T[K] extends AngularCore.EventEmitter ? (e: E) => void : T[K]; }; From 21c245e965ac3963a0a387027dc8813fa2e4c212 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Tue, 19 May 2026 10:55:57 +0200 Subject: [PATCH 02/17] feat(angular): detect model() signal binding at runtime (Layer C) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends getComponentInputsOutputs with an additive dual detection path so a model() field surfaces BOTH its `color` input and the compiler-synthesized `colorChange` output, without altering @Input/@Output/input()/output()/ EventEmitter results from the decorator path. Strategy (Probe C: esbuild/JIT strips signal AOT metadata in the unit-test harness, so `ecmp` I/O maps are empty for signal members / `signals===false`): - Primary: read the Angular component def via ɵgetComponentDef; ɵcmp keys the I/O maps by template name -> propName (verified empirically), so aliased model(x,{alias}) and model.required() resolve to their real binding names. - Fallback: synthesize from the component instance brand (writable+subscribable signal => model() input + ${name}Change output) for non-AOT/JIT classes. All 3 consumers verified (no edits needed): computesTemplateFromComponent and computesTemplateSourceFromComponent emit [color]+(colorChange) via the existing pure I/O builders; StorybookWrapperComponent filter-inversion confirmed -- the model input now reaches the instance through the template Input binding (initial render + live storyProps$ updates) instead of the dropped getNonInputsOutputsProps direct-assignment path. Adds a NEW factory-free test block covering @Input/@Output, input(), output(), EventEmitter, model(), model.required(), aliased model(), plus a throws-if-called ComponentFactoryResolver guard proving zero factory invocation in model() detection. L50-212 kept commented with a tracked TODO(angular-22). Refs https://github.com/storybookjs/storybook/issues/34831 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../utils/NgComponentAnalyzer.test.ts | 152 ++++++++++++- .../angular-beta/utils/NgComponentAnalyzer.ts | 205 +++++++++++++++--- 2 files changed, 320 insertions(+), 37 deletions(-) diff --git a/code/frameworks/angular/src/client/angular-beta/utils/NgComponentAnalyzer.test.ts b/code/frameworks/angular/src/client/angular-beta/utils/NgComponentAnalyzer.test.ts index 39c77e48ca13..ab84f80345d2 100644 --- a/code/frameworks/angular/src/client/angular-beta/utils/NgComponentAnalyzer.test.ts +++ b/code/frameworks/angular/src/client/angular-beta/utils/NgComponentAnalyzer.test.ts @@ -3,8 +3,11 @@ import type { Type } from '@angular/core'; import { Component, - // Removed in Angular 22 - // ComponentFactoryResolver, + // `ComponentFactoryResolver` is the abstract symbol the (now-commented) factory + // test path used via `TestBed.inject(ComponentFactoryResolver)`. It is still + // exported in Angular 22; we use it only as a throws-if-called decouple guard + // (its `resolveComponentFactory` method is abstract / not on the prototype). + ComponentFactoryResolver, Directive, EventEmitter, HostBinding, @@ -13,11 +16,12 @@ import { Output, Pipe, input, + model, output, } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { BrowserDynamicTestingModule } from '@angular/platform-browser-dynamic/testing'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { getComponentInputsOutputs, @@ -47,6 +51,12 @@ describe('getComponentInputsOutputs', () => { }); }); + // TODO(angular-22): re-enable factory-comparison assertions when ComponentFactoryResolver path is resolved + // Every `it()` in the block below asserts against `resolveComponentFactory(...)`, which relies on the + // Angular-22-removed `ComponentFactoryResolver`. There is no separable non-factory half, so these stay + // commented; the live `describe('getComponentInputsOutputs (non-factory)')` block further down provides + // equivalent factory-free coverage (incl. `model()`). See + // https://github.com/storybookjs/storybook/issues/34831 /* Commented out until we figure out how to handle the removal of ComponentFactoryResolver in Angular 22 See https://github.com/angular/angular/releases/tag/v22.0.0-next.7 @@ -212,6 +222,142 @@ describe('getComponentInputsOutputs', () => { */ }); +describe('getComponentInputsOutputs (non-factory)', () => { + // These assertions never CALL `resolveComponentFactory` / a `ComponentFactoryResolver` + // instance to derive I/O (the Angular-22-affected factory path the commented block + // above relied on). They cover the same I/O detection surface as that block, plus + // `model()` signal detection, using literal expected shapes only. `ComponentFactoryResolver` + // is imported solely by the final test as a throws-if-called decouple guard. + + it('detects @Input / @Output (decorator path, unchanged)', () => { + @Component({ template: '', standalone: false }) + class FooComponent { + @Input() public input: string; + + @Input('inputPropertyName') public inputWithBindingPropertyName: string; + + @Output() public output = new EventEmitter(); + + @Output('outputPropertyName') public outputWithBindingPropertyName = + new EventEmitter(); + } + + const { inputs, outputs } = getComponentInputsOutputs(FooComponent); + + expect(sortByPropName(inputs)).toEqual( + sortByPropName([ + { propName: 'input', templateName: 'input' }, + { propName: 'inputWithBindingPropertyName', templateName: 'inputPropertyName' }, + ]) + ); + expect(sortByPropName(outputs)).toEqual( + sortByPropName([ + { propName: 'output', templateName: 'output' }, + { propName: 'outputWithBindingPropertyName', templateName: 'outputPropertyName' }, + ]) + ); + }); + + it('detects input() / output() signal members', () => { + @Component({ template: '', standalone: true }) + class FooComponent { + public signalInput = input(); + + public signalOutput = output(); + } + + const { inputs, outputs } = getComponentInputsOutputs(FooComponent); + + expect(inputs).toContainEqual({ propName: 'signalInput', templateName: 'signalInput' }); + expect(outputs).toContainEqual({ propName: 'signalOutput', templateName: 'signalOutput' }); + }); + + it('detects EventEmitter @Output', () => { + @Component({ template: '', standalone: true }) + class FooComponent { + @Output() public emitter = new EventEmitter(); + } + + const { outputs } = getComponentInputsOutputs(FooComponent); + + expect(outputs).toContainEqual({ propName: 'emitter', templateName: 'emitter' }); + }); + + it('detects model() as both an input and a synthesized `${name}Change` output', () => { + @Component({ template: '', standalone: true }) + class FooComponent { + public color = model(); + + public reqd = model.required(); + + public aliased = model(undefined, { alias: 'al' }); + } + + const { inputs, outputs } = getComponentInputsOutputs(FooComponent); + + // model() field surfaces BOTH the `color` input AND a `colorChange` output. + expect(inputs).toContainEqual({ propName: 'color', templateName: 'color' }); + expect(outputs).toContainEqual({ propName: 'color', templateName: 'colorChange' }); + + // model.required() behaves identically. + expect(inputs).toContainEqual({ propName: 'reqd', templateName: 'reqd' }); + expect(outputs).toContainEqual({ propName: 'reqd', templateName: 'reqdChange' }); + + // Aliased model(): the runtime alias (`al`/`alChange`) is only resolvable via + // `ɵcmp` at real AOT runtime. In this JIT/esbuild unit-test harness `ɵcmp` is + // empty for signal members (Probe C), so the bespoke fallback synthesizes from + // the property name (`aliased`/`aliasedChange`). This is the documented + // harness-only fallback shape, not a regression: at AOT the primary + // `ɵgetComponentDef` path yields the resolved `al`/`alChange` names. + expect(inputs.some((i) => i.propName === 'aliased')).toBe(true); + expect(outputs.some((o) => o.propName === 'aliased' && o.templateName.endsWith('Change'))).toBe( + true + ); + }); + + it('never invokes resolveComponentFactory for model() detection', () => { + // R6 decouple proof (PREFERRED mechanism — throws-if-called guard). + // + // The live `@angular/core` ESM namespace is non-extensible and exports no + // top-level `resolveComponentFactory` (it was only ever a method on the + // abstract `ComponentFactoryResolver`, the symbol the now-commented factory + // test path consumed via `TestBed.inject(ComponentFactoryResolver)`). + // `ComponentFactoryResolver` is still exported and its prototype IS + // extensible (the `resolveComponentFactory` method is abstract, so absent + // from the prototype). We install a throws-if-called `resolveComponentFactory` + // on that prototype: if the runtime model() path had ANY residual + // `ComponentFactoryResolver` coupling it would invoke this and throw. The + // detection completing while the guard is never called proves the path is + // purely instance/component-def based. + const proto = ComponentFactoryResolver.prototype as unknown as Record; + const hadOwn = Object.prototype.hasOwnProperty.call(proto, 'resolveComponentFactory'); + const original = proto.resolveComponentFactory; + const throwIfCalled = vi.fn(() => { + throw new Error('resolveComponentFactory must not be invoked by model() detection'); + }); + try { + proto.resolveComponentFactory = throwIfCalled; + + @Component({ template: '', standalone: true }) + class FooComponent { + public color = model(); + } + + const { inputs, outputs } = getComponentInputsOutputs(FooComponent); + + expect(inputs).toContainEqual({ propName: 'color', templateName: 'color' }); + expect(outputs).toContainEqual({ propName: 'color', templateName: 'colorChange' }); + expect(throwIfCalled).not.toHaveBeenCalled(); + } finally { + if (hadOwn) { + proto.resolveComponentFactory = original; + } else { + delete proto.resolveComponentFactory; + } + } + }); +}); + describe('isDeclarable', () => { it('should return true with a Component', () => { @Component({}) diff --git a/code/frameworks/angular/src/client/angular-beta/utils/NgComponentAnalyzer.ts b/code/frameworks/angular/src/client/angular-beta/utils/NgComponentAnalyzer.ts index da072c2c646d..d88d72d4ecc2 100644 --- a/code/frameworks/angular/src/client/angular-beta/utils/NgComponentAnalyzer.ts +++ b/code/frameworks/angular/src/client/angular-beta/utils/NgComponentAnalyzer.ts @@ -2,10 +2,15 @@ import type { Type } from '@angular/core'; import { Component, Directive, + Injector, Input, + OutputEmitterRef, Output, Pipe, + isSignal, + runInInjectionContext, ɵReflectionCapabilities as ReflectionCapabilities, + ɵgetComponentDef as getComponentDef, } from '@angular/core'; const reflectionCapabilities = new ReflectionCapabilities(); @@ -46,44 +51,176 @@ export const getComponentInputsOutputs = (component: any): ComponentInputsOutput ); } - if (!componentPropsMetadata) { - return initialValue; - } - // Browses component properties to extract I/O // Filters properties that have the same name as the one present in the @Component property - return Object.entries(componentPropsMetadata).reduce((previousValue, [propertyName, values]) => { - const value = values.find((v) => v instanceof Input || v instanceof Output); - if (value instanceof Input) { - const inputToAdd = { - propName: propertyName, - templateName: value.bindingPropertyName ?? value.alias ?? propertyName, - }; - - const previousInputsFiltered = previousValue.inputs.filter( - (i) => i.templateName !== propertyName - ); - return { - ...previousValue, - inputs: [...previousInputsFiltered, inputToAdd], - }; + const decoratorDerived: ComponentInputsOutputs = !componentPropsMetadata + ? initialValue + : Object.entries(componentPropsMetadata).reduce((previousValue, [propertyName, values]) => { + const value = values.find((v) => v instanceof Input || v instanceof Output); + if (value instanceof Input) { + const inputToAdd = { + propName: propertyName, + templateName: value.bindingPropertyName ?? value.alias ?? propertyName, + }; + + const previousInputsFiltered = previousValue.inputs.filter( + (i) => i.templateName !== propertyName + ); + return { + ...previousValue, + inputs: [...previousInputsFiltered, inputToAdd], + }; + } + if (value instanceof Output) { + const outputToAdd = { + propName: propertyName, + templateName: value.bindingPropertyName ?? value.alias ?? propertyName, + }; + + const previousOutputsFiltered = previousValue.outputs.filter( + (i) => i.templateName !== propertyName + ); + return { + ...previousValue, + outputs: [...previousOutputsFiltered, outputToAdd], + }; + } + return previousValue; + }, initialValue); + + // Additively surface signal-based I/O (`input()`, `output()`, `model()`), which carry + // no decorator metadata and are therefore invisible to the decorator path above. + // This is intentionally additive and never mutates the decorator-derived results, so + // `@Input`/`@Output`/`EventEmitter` behavior is unchanged (zero regression). + return addSignalInputsOutputs(component, decoratorDerived); +}; + +const hasEntry = ( + list: { propName: string; templateName: string }[], + propName: string, + templateName: string +) => list.some((e) => e.propName === propName || e.templateName === templateName); + +/** + * Surfaces signal-based I/O that the decorator-reflection path cannot see. + * + * Angular's `model()` lowers to a binding pair: an `x` input + a compiler-synthesized + * `xChange` output. `input()`/`output()` are likewise decorator-less. None of these appear + * in `ɵReflectionCapabilities.propMetadata`, so without this path Storybook never binds + * them at runtime nor wires up the `xChange` action. + * + * Two complementary strategies are used (see + * `.omc/plans/probe-results-angular-model-signal-outputs.md`, Probe C): + * + * 1. Primary — read the Angular component definition (`ɵcmp` via `ɵgetComponentDef`). + * At real AOT runtime (the Angular builder used by Storybook/sandboxes) `ɵcmp.inputs` + * and `ɵcmp.outputs` already encode the *resolved* binding names, so aliased + * `model(x, { alias })` and `model.required()` are handled correctly here. + * + * 2. Fallback — a `model()`/`input()`/`output()`-aware synthesis from the component + * instance shape. In the `@storybook/angular` JIT/esbuild unit-test harness (and any + * consumer receiving a non-AOT-compiled class) esbuild strips the AOT signal metadata + * and the JIT compiler cannot reflect decorator-less signal members, so `ɵcmp.inputs` + * / `ɵcmp.outputs` are empty for signal members (`ɵcmp.signals === false`). The + * fallback detects the runtime brand of each instance field instead. + * + * Both paths are additive and de-duplicated against the decorator-derived results. + */ +const addSignalInputsOutputs = ( + component: any, + base: ComponentInputsOutputs +): ComponentInputsOutputs => { + const result: ComponentInputsOutputs = { + inputs: [...base.inputs], + outputs: [...base.outputs], + }; + + // 1. Primary: Angular component definition (resolved binding names, AOT-correct). + try { + const def: any = getComponentDef(component); + if (def) { + // Angular's `ɵcmp` def keys the I/O maps by the *template* (public/binding) + // name, NOT the class property name: + // def.inputs: { [templateName]: propName | [propName, flags, transform] } + // def.outputs: { [templateName]: propName } + // (verified empirically; aliased `@Input('a') b` → def.inputs = { a: ['b',…] }). + for (const templateName of Object.keys(def.inputs ?? {})) { + const rawPropName = def.inputs[templateName]; + const propName = Array.isArray(rawPropName) + ? (rawPropName[0] ?? templateName) + : (rawPropName ?? templateName); + if (!hasEntry(result.inputs, propName, templateName)) { + result.inputs.push({ propName, templateName }); + } + } + for (const templateName of Object.keys(def.outputs ?? {})) { + const propName = def.outputs[templateName] ?? templateName; + if (!hasEntry(result.outputs, propName, templateName)) { + result.outputs.push({ propName, templateName }); + } + } } - if (value instanceof Output) { - const outputToAdd = { - propName: propertyName, - templateName: value.bindingPropertyName ?? value.alias ?? propertyName, - }; - - const previousOutputsFiltered = previousValue.outputs.filter( - (i) => i.templateName !== propertyName - ); - return { - ...previousValue, - outputs: [...previousOutputsFiltered, outputToAdd], - }; + } catch { + // `ɵgetComponentDef` may be unavailable for non-component classes; ignore. + } + + // 2. Fallback: synthesize from the component instance shape when signal members were + // not surfaced by the component definition (non-AOT/JIT-compiled classes). + try { + let instance: any; + runInInjectionContext(Injector.create({ providers: [] }), () => { + instance = new component(); + }); + + if (instance) { + for (const propName of Object.keys(instance)) { + const member = instance[propName]; + if (member == null) { + continue; + } + + // `isSignal()` narrows `member` to `Signal`, which does not expose the + // writable (`set`/`update`) or subscribable (`subscribe`) members that brand a + // `model()`/`output()` at runtime. Probe those off an un-narrowed reference. + const memberAny = member as any; + const isWritableSignal = + isSignal(member) && + typeof memberAny.set === 'function' && + typeof memberAny.update === 'function'; + const isSubscribable = typeof memberAny.subscribe === 'function'; + + if (isWritableSignal && isSubscribable) { + // `model()` / `model.required()`: input `x` + synthesized output `xChange`. + // The runtime alias is not observable on the instance; the resolved binding + // name is only available via `ɵcmp` (handled by the primary path at AOT). + const changeName = `${propName}Change`; + if (!hasEntry(result.inputs, propName, propName)) { + result.inputs.push({ propName, templateName: propName }); + } + if (!hasEntry(result.outputs, propName, changeName)) { + result.outputs.push({ propName, templateName: changeName }); + } + } else if (isSignal(member)) { + // `input()` / `input.required()`: writable-less signal → input only. + if (!hasEntry(result.inputs, propName, propName)) { + result.inputs.push({ propName, templateName: propName }); + } + } else if (member instanceof OutputEmitterRef) { + // `output()`: not a signal, exposes `subscribe` → output only. + if (!hasEntry(result.outputs, propName, propName)) { + result.outputs.push({ propName, templateName: propName }); + } + } + } } - return previousValue; - }, initialValue); + } catch { + // The component may not be instantiable outside its real DI context (e.g. it + // requires constructor dependencies). The primary path above already covers the + // AOT runtime case; failing instantiation here is non-fatal and just means no + // extra fallback-derived signal members are added. + } + + return result; }; export const isDeclarable = (component: any): boolean => { From 0588d2f5eeb4c809cbd84ea575b2881df775b68c Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Tue, 19 May 2026 11:09:34 +0200 Subject: [PATCH 03/17] feat(angular): detect model() signal output in compodoc autodocs (Layer B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs https://github.com/storybookjs/storybook/issues/34831 Angular `model()` two-way binding signals were not surfaced in compodoc autodocs. compodoc (verified against the captured v1.2.1 output) emits a `model()` member as an IDENTICAL entry — same bare name, no decorators/ jsdoctags, ModelSignal wrapper erased — in BOTH `inputsClass` AND `outputsClass`. Plain @Input/input() only land in inputsClass; plain @Output/output()/EventEmitter only in outputsClass. The reliable, version-tolerant discriminator is therefore a property whose name appears in BOTH arrays of the same component (the both-arrays discriminator); compodoc emits no model() marker, so compodoc-types.ts Property is unchanged. - extractArgTypesFromData now detects model props via the both-arrays discriminator, suppresses compodoc's spurious bare-name outputsClass duplicate (model surfaces as an input control), and synthesizes a `${name}Change` output (action: '${name}Change') reusing the per-item output shape. - Deterministic angularFilterNonInputControls re-surface branch: the synthesized `${name}Change` output + companion input are present with the flag OFF, and still re-surfaced with the flag ON despite iteration being restricted to ['inputsClass']. - New __testfixtures__/doc-model fixture (mirrors doc-button) with the captured compodoc JSON; angular-properties.test.ts now asserts the synthesized colorChange/showTextChange rows for both filter states. - doc-button EventEmitter fixture regression-guarded (untouched, still green). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../frameworks/angular/src/client/compodoc.ts | 69 ++++++++++ .../doc-model/argtypes-filtered.snapshot | 74 +++++++++++ .../doc-model/argtypes.snapshot | 74 +++++++++++ .../doc-model/compodoc-input.json | 118 +++++++++++++++++ .../doc-model/compodoc-posix.snapshot | 124 ++++++++++++++++++ .../doc-model/compodoc-undefined.snapshot | 124 ++++++++++++++++++ .../doc-model/compodoc-windows.snapshot | 124 ++++++++++++++++++ .../docs/__testfixtures__/doc-model/input.ts | 17 +++ .../__testfixtures__/doc-model/tsconfig.json | 7 + .../client/docs/angular-properties.test.ts | 123 +++++++++++++---- 10 files changed, 827 insertions(+), 27 deletions(-) create mode 100644 code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/argtypes-filtered.snapshot create mode 100644 code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/argtypes.snapshot create mode 100644 code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/compodoc-input.json create mode 100644 code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/compodoc-posix.snapshot create mode 100644 code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/compodoc-undefined.snapshot create mode 100644 code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/compodoc-windows.snapshot create mode 100644 code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/input.ts create mode 100644 code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/tsconfig.json diff --git a/code/frameworks/angular/src/client/compodoc.ts b/code/frameworks/angular/src/client/compodoc.ts index 219010434c1c..a5aa8a36f110 100644 --- a/code/frameworks/angular/src/client/compodoc.ts +++ b/code/frameworks/angular/src/client/compodoc.ts @@ -239,10 +239,39 @@ export const extractArgTypesFromData = (componentData: Class | Directive | Injec | 'inputsClass' | 'outputsClass'; + // Detect Angular `model()` signals. + // + // compodoc (verified against the captured v1.2.1 output at + // `.omc/plans/probe-fixtures/compodoc-model-probe-documentation.json`) emits a + // `model()` member as an IDENTICAL entry — same bare name, e.g. `color` — in BOTH + // `inputsClass` AND `outputsClass`, with no `decorators`/`jsdoctags` and the + // `ModelSignal` wrapper erased to the unwrapped value type. Plain + // `@Input`/`input()` only land in `inputsClass`; plain `@Output`/`output()`/ + // `EventEmitter` only land in `outputsClass` (and never under the input's name). + // The only reliable, version-tolerant discriminator is therefore a property whose + // name appears in BOTH arrays of the same component. + const inputClassNames = new Set( + (((componentData as any).inputsClass as Property[]) || []).map((item) => item.name) + ); + const modelProperties: Property[] = ( + ((componentData as any).outputsClass as Property[]) || [] + ).filter((item) => inputClassNames.has(item.name)); + const modelPropertyNames = new Set(modelProperties.map((item) => item.name)); + compodocClasses.forEach((key: COMPODOC_CLASS) => { const data = (componentData as any)[key] || []; data.forEach((item: Method | Property) => { const section = mapItemToSection(key, item); + + // Suppress compodoc's spurious `outputsClass` duplicate of a `model()` property. + // The model property must surface as an INPUT control (via its `inputsClass` + // entry); the corresponding output is the synthesized `${name}Change` added + // below — not a plain bare-name output. See the model() detection note above + // (`.omc/plans/probe-fixtures/compodoc-model-probe-documentation.json`). + if (key === 'outputsClass' && !isMethod(item) && modelPropertyNames.has(item.name)) { + return; + } + const defaultValue = isMethod(item) ? undefined : extractDefaultValue(item as Property); const type: SBType = @@ -273,6 +302,46 @@ export const extractArgTypesFromData = (componentData: Class | Directive | Injec }); }); + // Synthesize the `${name}Change` output for every detected `model()` property. + // + // compodoc does NOT emit a `${name}Change` member (it merely duplicates the + // property under its bare name into `outputsClass`), so Storybook synthesizes the + // two-way `${name}Change` output here, reusing the per-item output shape above. + // + // This runs unconditionally, AFTER the iteration loop, so it is deterministic + // across both `FEATURES.angularFilterNonInputControls` states: + // - flag OFF: the model input control comes from `inputsClass`; the spurious + // bare-name `outputsClass` duplicate is suppressed above; `${name}Change` is + // added here. + // - flag ON: iteration is restricted to `['inputsClass']` (filter L227-229), so + // the model input control still surfaces, and `${name}Change` is re-surfaced + // here despite `outputsClass` never being iterated. + // Evidence basis: `.omc/plans/probe-fixtures/compodoc-model-probe-documentation.json`. + modelProperties.forEach((item) => { + const changeName = `${item.name}Change`; + const defaultValue = extractDefaultValue(item); + + const argType = { + name: changeName, + description: item.rawdescription || item.description, + type: { name: 'other', value: 'void' } as SBType, + action: changeName, + table: { + category: 'outputs', + type: { + summary: item.type, + required: !item.optional, + }, + defaultValue: { summary: defaultValue }, + }, + }; + + if (!sectionToItems.outputs) { + sectionToItems.outputs = []; + } + sectionToItems.outputs.push(argType); + }); + const SECTIONS = [ 'properties', 'inputs', diff --git a/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/argtypes-filtered.snapshot b/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/argtypes-filtered.snapshot new file mode 100644 index 000000000000..a6d20e8a8d05 --- /dev/null +++ b/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/argtypes-filtered.snapshot @@ -0,0 +1,74 @@ +{ + "color": { + "description": "", + "name": "color", + "table": { + "category": "inputs", + "defaultValue": { + "summary": "#345F92", + }, + "type": { + "required": true, + "summary": "string", + }, + }, + "type": { + "name": "string", + }, + }, + "colorChange": { + "action": "colorChange", + "description": "", + "name": "colorChange", + "table": { + "category": "outputs", + "defaultValue": { + "summary": "#345F92", + }, + "type": { + "required": true, + "summary": "string", + }, + }, + "type": { + "name": "other", + "value": "void", + }, + }, + "showText": { + "description": "", + "name": "showText", + "table": { + "category": "inputs", + "defaultValue": { + "summary": false, + }, + "type": { + "required": true, + "summary": "boolean", + }, + }, + "type": { + "name": "boolean", + }, + }, + "showTextChange": { + "action": "showTextChange", + "description": "", + "name": "showTextChange", + "table": { + "category": "outputs", + "defaultValue": { + "summary": false, + }, + "type": { + "required": true, + "summary": "boolean", + }, + }, + "type": { + "name": "other", + "value": "void", + }, + }, +} \ No newline at end of file diff --git a/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/argtypes.snapshot b/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/argtypes.snapshot new file mode 100644 index 000000000000..a6d20e8a8d05 --- /dev/null +++ b/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/argtypes.snapshot @@ -0,0 +1,74 @@ +{ + "color": { + "description": "", + "name": "color", + "table": { + "category": "inputs", + "defaultValue": { + "summary": "#345F92", + }, + "type": { + "required": true, + "summary": "string", + }, + }, + "type": { + "name": "string", + }, + }, + "colorChange": { + "action": "colorChange", + "description": "", + "name": "colorChange", + "table": { + "category": "outputs", + "defaultValue": { + "summary": "#345F92", + }, + "type": { + "required": true, + "summary": "string", + }, + }, + "type": { + "name": "other", + "value": "void", + }, + }, + "showText": { + "description": "", + "name": "showText", + "table": { + "category": "inputs", + "defaultValue": { + "summary": false, + }, + "type": { + "required": true, + "summary": "boolean", + }, + }, + "type": { + "name": "boolean", + }, + }, + "showTextChange": { + "action": "showTextChange", + "description": "", + "name": "showTextChange", + "table": { + "category": "outputs", + "defaultValue": { + "summary": false, + }, + "type": { + "required": true, + "summary": "boolean", + }, + }, + "type": { + "name": "other", + "value": "void", + }, + }, +} \ No newline at end of file diff --git a/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/compodoc-input.json b/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/compodoc-input.json new file mode 100644 index 000000000000..1dfb80c78da3 --- /dev/null +++ b/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/compodoc-input.json @@ -0,0 +1,118 @@ +{ + "pipes": [], + "interfaces": [], + "injectables": [], + "guards": [], + "interceptors": [], + "classes": [], + "directives": [], + "components": [ + { + "name": "ColorPickerComponent", + "id": "component-ColorPickerComponent-ee805ddb7f60da308cdcc8df0001da2e5a083f17f41e20836c705aea547615150fda99a5fd1a529c7916959272247b37e65a7aad6e078224274403e7f3a9f84b", + "file": "color-picker.component.ts", + "encapsulation": [], + "entryComponents": [], + "inputs": [], + "outputs": [], + "providers": [], + "selector": "cp", + "styleUrls": [], + "styles": [], + "template": "", + "templateUrl": [], + "viewProviders": [], + "hostDirectives": [], + "inputsClass": [ + { + "name": "color", + "defaultValue": "'#345F92'", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "indexKey": "", + "optional": false, + "description": "", + "line": 5, + "modifierKind": [125, 148], + "required": false + }, + { + "name": "showText", + "deprecated": false, + "deprecationMessage": "", + "type": "boolean", + "indexKey": "", + "optional": false, + "description": "", + "line": 7, + "required": true + } + ], + "outputsClass": [ + { + "name": "color", + "defaultValue": "'#345F92'", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "indexKey": "", + "optional": false, + "description": "", + "line": 5, + "modifierKind": [125, 148], + "required": false + }, + { + "name": "showText", + "deprecated": false, + "deprecationMessage": "", + "type": "boolean", + "indexKey": "", + "optional": false, + "description": "", + "line": 7, + "required": true + } + ], + "propertiesClass": [], + "methodsClass": [], + "deprecated": false, + "deprecationMessage": "", + "hostBindings": [], + "hostListeners": [], + "standalone": false, + "imports": [], + "description": "", + "rawdescription": "\n", + "type": "component", + "sourceCode": "import { Component, model } from '@angular/core';\n\n@Component({ selector: 'cp', template: '' })\nexport class ColorPickerComponent {\n public readonly color = model('#345F92');\n\n showText = model.required();\n}\n", + "assetsDirs": [], + "styleUrlsData": "", + "stylesData": "", + "extends": [] + } + ], + "modules": [], + "miscellaneous": [], + "routes": { + "name": "", + "kind": "module", + "children": [] + }, + "coverage": { + "count": 0, + "status": "low", + "files": [ + { + "filePath": "color-picker.component.ts", + "type": "component", + "linktype": "component", + "name": "ColorPickerComponent", + "coveragePercent": 0, + "coverageCount": "0/5", + "status": "low" + } + ] + } +} diff --git a/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/compodoc-posix.snapshot b/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/compodoc-posix.snapshot new file mode 100644 index 000000000000..5fb5ba06a43e --- /dev/null +++ b/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/compodoc-posix.snapshot @@ -0,0 +1,124 @@ +{ + "pipes": [], + "interfaces": [], + "injectables": [], + "guards": [], + "interceptors": [], + "classes": [], + "directives": [], + "components": [ + { + "name": "ColorPickerComponent", + "id": "component-ColorPickerComponent-ee805ddb7f60da308cdcc8df0001da2e5a083f17f41e20836c705aea547615150fda99a5fd1a529c7916959272247b37e65a7aad6e078224274403e7f3a9f84b", + "file": "color-picker.component.ts", + "encapsulation": [], + "entryComponents": [], + "inputs": [], + "outputs": [], + "providers": [], + "selector": "cp", + "styleUrls": [], + "styles": [], + "template": "", + "templateUrl": [], + "viewProviders": [], + "hostDirectives": [], + "inputsClass": [ + { + "name": "color", + "defaultValue": "'#345F92'", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "indexKey": "", + "optional": false, + "description": "", + "line": 5, + "modifierKind": [ + 125, + 148 + ], + "required": false + }, + { + "name": "showText", + "deprecated": false, + "deprecationMessage": "", + "type": "boolean", + "indexKey": "", + "optional": false, + "description": "", + "line": 7, + "required": true + } + ], + "outputsClass": [ + { + "name": "color", + "defaultValue": "'#345F92'", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "indexKey": "", + "optional": false, + "description": "", + "line": 5, + "modifierKind": [ + 125, + 148 + ], + "required": false + }, + { + "name": "showText", + "deprecated": false, + "deprecationMessage": "", + "type": "boolean", + "indexKey": "", + "optional": false, + "description": "", + "line": 7, + "required": true + } + ], + "propertiesClass": [], + "methodsClass": [], + "deprecated": false, + "deprecationMessage": "", + "hostBindings": [], + "hostListeners": [], + "standalone": false, + "imports": [], + "description": "", + "rawdescription": "\n", + "type": "component", + "sourceCode": "import { Component, model } from '@angular/core';\n\n@Component({ selector: 'cp', template: '' })\nexport class ColorPickerComponent {\n public readonly color = model('#345F92');\n\n showText = model.required();\n}\n", + "assetsDirs": [], + "styleUrlsData": "", + "stylesData": "", + "extends": [] + } + ], + "modules": [], + "miscellaneous": [], + "routes": { + "name": "", + "kind": "module", + "children": [] + }, + "coverage": { + "count": 0, + "status": "low", + "files": [ + { + "filePath": "color-picker.component.ts", + "type": "component", + "linktype": "component", + "name": "ColorPickerComponent", + "coveragePercent": 0, + "coverageCount": "0/5", + "status": "low" + } + ] + } +} \ No newline at end of file diff --git a/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/compodoc-undefined.snapshot b/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/compodoc-undefined.snapshot new file mode 100644 index 000000000000..5fb5ba06a43e --- /dev/null +++ b/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/compodoc-undefined.snapshot @@ -0,0 +1,124 @@ +{ + "pipes": [], + "interfaces": [], + "injectables": [], + "guards": [], + "interceptors": [], + "classes": [], + "directives": [], + "components": [ + { + "name": "ColorPickerComponent", + "id": "component-ColorPickerComponent-ee805ddb7f60da308cdcc8df0001da2e5a083f17f41e20836c705aea547615150fda99a5fd1a529c7916959272247b37e65a7aad6e078224274403e7f3a9f84b", + "file": "color-picker.component.ts", + "encapsulation": [], + "entryComponents": [], + "inputs": [], + "outputs": [], + "providers": [], + "selector": "cp", + "styleUrls": [], + "styles": [], + "template": "", + "templateUrl": [], + "viewProviders": [], + "hostDirectives": [], + "inputsClass": [ + { + "name": "color", + "defaultValue": "'#345F92'", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "indexKey": "", + "optional": false, + "description": "", + "line": 5, + "modifierKind": [ + 125, + 148 + ], + "required": false + }, + { + "name": "showText", + "deprecated": false, + "deprecationMessage": "", + "type": "boolean", + "indexKey": "", + "optional": false, + "description": "", + "line": 7, + "required": true + } + ], + "outputsClass": [ + { + "name": "color", + "defaultValue": "'#345F92'", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "indexKey": "", + "optional": false, + "description": "", + "line": 5, + "modifierKind": [ + 125, + 148 + ], + "required": false + }, + { + "name": "showText", + "deprecated": false, + "deprecationMessage": "", + "type": "boolean", + "indexKey": "", + "optional": false, + "description": "", + "line": 7, + "required": true + } + ], + "propertiesClass": [], + "methodsClass": [], + "deprecated": false, + "deprecationMessage": "", + "hostBindings": [], + "hostListeners": [], + "standalone": false, + "imports": [], + "description": "", + "rawdescription": "\n", + "type": "component", + "sourceCode": "import { Component, model } from '@angular/core';\n\n@Component({ selector: 'cp', template: '' })\nexport class ColorPickerComponent {\n public readonly color = model('#345F92');\n\n showText = model.required();\n}\n", + "assetsDirs": [], + "styleUrlsData": "", + "stylesData": "", + "extends": [] + } + ], + "modules": [], + "miscellaneous": [], + "routes": { + "name": "", + "kind": "module", + "children": [] + }, + "coverage": { + "count": 0, + "status": "low", + "files": [ + { + "filePath": "color-picker.component.ts", + "type": "component", + "linktype": "component", + "name": "ColorPickerComponent", + "coveragePercent": 0, + "coverageCount": "0/5", + "status": "low" + } + ] + } +} \ No newline at end of file diff --git a/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/compodoc-windows.snapshot b/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/compodoc-windows.snapshot new file mode 100644 index 000000000000..5fb5ba06a43e --- /dev/null +++ b/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/compodoc-windows.snapshot @@ -0,0 +1,124 @@ +{ + "pipes": [], + "interfaces": [], + "injectables": [], + "guards": [], + "interceptors": [], + "classes": [], + "directives": [], + "components": [ + { + "name": "ColorPickerComponent", + "id": "component-ColorPickerComponent-ee805ddb7f60da308cdcc8df0001da2e5a083f17f41e20836c705aea547615150fda99a5fd1a529c7916959272247b37e65a7aad6e078224274403e7f3a9f84b", + "file": "color-picker.component.ts", + "encapsulation": [], + "entryComponents": [], + "inputs": [], + "outputs": [], + "providers": [], + "selector": "cp", + "styleUrls": [], + "styles": [], + "template": "", + "templateUrl": [], + "viewProviders": [], + "hostDirectives": [], + "inputsClass": [ + { + "name": "color", + "defaultValue": "'#345F92'", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "indexKey": "", + "optional": false, + "description": "", + "line": 5, + "modifierKind": [ + 125, + 148 + ], + "required": false + }, + { + "name": "showText", + "deprecated": false, + "deprecationMessage": "", + "type": "boolean", + "indexKey": "", + "optional": false, + "description": "", + "line": 7, + "required": true + } + ], + "outputsClass": [ + { + "name": "color", + "defaultValue": "'#345F92'", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "indexKey": "", + "optional": false, + "description": "", + "line": 5, + "modifierKind": [ + 125, + 148 + ], + "required": false + }, + { + "name": "showText", + "deprecated": false, + "deprecationMessage": "", + "type": "boolean", + "indexKey": "", + "optional": false, + "description": "", + "line": 7, + "required": true + } + ], + "propertiesClass": [], + "methodsClass": [], + "deprecated": false, + "deprecationMessage": "", + "hostBindings": [], + "hostListeners": [], + "standalone": false, + "imports": [], + "description": "", + "rawdescription": "\n", + "type": "component", + "sourceCode": "import { Component, model } from '@angular/core';\n\n@Component({ selector: 'cp', template: '' })\nexport class ColorPickerComponent {\n public readonly color = model('#345F92');\n\n showText = model.required();\n}\n", + "assetsDirs": [], + "styleUrlsData": "", + "stylesData": "", + "extends": [] + } + ], + "modules": [], + "miscellaneous": [], + "routes": { + "name": "", + "kind": "module", + "children": [] + }, + "coverage": { + "count": 0, + "status": "low", + "files": [ + { + "filePath": "color-picker.component.ts", + "type": "component", + "linktype": "component", + "name": "ColorPickerComponent", + "coveragePercent": 0, + "coverageCount": "0/5", + "status": "low" + } + ] + } +} \ No newline at end of file diff --git a/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/input.ts b/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/input.ts new file mode 100644 index 000000000000..42f100be5832 --- /dev/null +++ b/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/input.ts @@ -0,0 +1,17 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck + +import { Component, model } from '@angular/core'; + +/** + * A component exercising Angular's `model()` two-way binding signal. + * + * compodoc emits a `model()` member as an identical entry in BOTH `inputsClass` and + * `outputsClass` (see `.omc/plans/probe-fixtures/compodoc-model-probe-documentation.json`). + */ +@Component({ selector: 'cp', template: '' }) +export class ColorPickerComponent { + public readonly color = model('#345F92'); + + showText = model.required(); +} diff --git a/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/tsconfig.json b/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/tsconfig.json new file mode 100644 index 000000000000..ced6b7ae2f7c --- /dev/null +++ b/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "rootDir": "." + }, + "include": ["./*.ts"] +} diff --git a/code/frameworks/angular/src/client/docs/angular-properties.test.ts b/code/frameworks/angular/src/client/docs/angular-properties.test.ts index a4531cbbe480..50e4c0cdcf1b 100644 --- a/code/frameworks/angular/src/client/docs/angular-properties.test.ts +++ b/code/frameworks/angular/src/client/docs/angular-properties.test.ts @@ -1,39 +1,108 @@ -import { readdirSync } from 'node:fs'; +import { readFileSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; + +import { extractArgTypesFromData, findComponentByName } from '../compodoc.ts'; +import type { CompodocJson } from '../compodoc-types.ts'; + +// `compodoc.ts` destructures `FEATURES` from the global scope at module-load time +// (`const { FEATURES } = global`). The destructure captures the OBJECT REFERENCE, so +// the flag object must exist on the global BEFORE the module is imported. `vi.hoisted` +// is lifted above the imports during transform, so this runs first; per-state changes +// are then applied by MUTATING that same object (never reassigning). +const featureFlags = vi.hoisted(() => { + const flags = { angularFilterNonInputControls: false }; + (globalThis as any).FEATURES = flags; + return flags; +}); // File hierarchy: __testfixtures__ / some-test-case / input.* const inputRegExp = /^input\..*$/; +// Mirrors the historic `SNAPSHOT_OS` global (removed with the legacy jest setup): +// compodoc output is path-sensitive, so snapshots are OS-suffixed. +const SNAPSHOT_OS = + process.platform === 'win32' ? 'windows' : process.platform ? 'posix' : 'undefined'; + +const extractWithFilter = ( + componentName: string, + compodocJson: CompodocJson, + angularFilterNonInputControls: boolean +) => { + featureFlags.angularFilterNonInputControls = angularFilterNonInputControls; + const componentData = findComponentByName(componentName, compodocJson); + return extractArgTypesFromData(componentData as any); +}; + describe('angular component properties', () => { const fixturesDir = join(__dirname, '__testfixtures__'); readdirSync(fixturesDir, { withFileTypes: true }).forEach((testEntry) => { - if (testEntry.isDirectory()) { - const testDir = join(fixturesDir, testEntry.name); - const testFile = readdirSync(testDir).find((fileName) => inputRegExp.test(fileName)); - if (testFile) { - // TODO: Remove this as soon as the real test is fixed - it('true', () => { - expect(true).toEqual(true); - }); - // TODO: Fix this test - // it(`${testEntry.name}`, async () => { - // const inputPath = join(testDir, testFile); - - // // snapshot the output of compodoc - // const compodocOutput = runCompodoc(inputPath); - // const compodocJson = JSON.parse(compodocOutput); - // await expect(compodocJson).toMatchFileSnapshot( - // join(testDir, `compodoc-${SNAPSHOT_OS}.snapshot`) - // ); - - // // snapshot the output of addon-docs angular-properties - // const componentData = findComponentByName('InputComponent', compodocJson); - // const argTypes = extractArgTypesFromData(componentData); - // await expect(argTypes).toMatchFileSnapshot(join(testDir, 'argtypes.snapshot')); - // }); - } + if (!testEntry.isDirectory()) { + return; + } + const testDir = join(fixturesDir, testEntry.name); + const dirEntries = readdirSync(testDir); + const testFile = dirEntries.find((fileName) => inputRegExp.test(fileName)); + if (!testFile) { + return; + } + + // compodoc is an external, unpinned tool that is not a repo dependency, so it + // cannot be invoked from the unit-test harness. Fixtures that ship a captured, + // parseable `compodoc-input.json` (the `model()` case captured by Probe B at + // `.omc/plans/probe-fixtures/compodoc-model-probe-documentation.json`) get the + // real `extractArgTypesFromData` assertions; legacy fixtures without it (which + // would require re-running compodoc) keep a trivial green test so they are not + // regressed. + const hasCapturedCompodocJson = dirEntries.includes('compodoc-input.json'); + if (!hasCapturedCompodocJson) { + it(`${testEntry.name} (compodoc capture not available)`, () => { + expect(true).toEqual(true); + }); + return; } + + const compodocJson = JSON.parse( + readFileSync(join(testDir, 'compodoc-input.json'), 'utf8') + ) as CompodocJson; + + it(`${testEntry.name}`, async () => { + // Snapshot the captured compodoc output (OS-suffixed, mirroring doc-button). + await expect(JSON.stringify(compodocJson, null, 2)).toMatchFileSnapshot( + join(testDir, `compodoc-${SNAPSHOT_OS}.snapshot`) + ); + + // angularFilterNonInputControls OFF (default): model input control + the + // synthesized `${name}Change` output both present; NO spurious bare-name output. + const argTypes = extractWithFilter('ColorPickerComponent', compodocJson, false); + await expect(argTypes).toMatchFileSnapshot(join(testDir, 'argtypes.snapshot')); + + expect(argTypes.color.table?.category).toBe('inputs'); + expect((argTypes.color as any).action).toBeUndefined(); + expect(argTypes.colorChange).toBeDefined(); + expect(argTypes.colorChange.table?.category).toBe('outputs'); + expect((argTypes.colorChange as any).action).toBe('colorChange'); + expect(argTypes.showText.table?.category).toBe('inputs'); + expect(argTypes.showTextChange).toBeDefined(); + expect((argTypes.showTextChange as any).action).toBe('showTextChange'); + + // angularFilterNonInputControls ON: iteration is restricted to `inputsClass` + // (compodoc.ts L227-229). The model input control AND the synthesized + // `${name}Change` output must STILL be re-surfaced. + const filteredArgTypes = extractWithFilter('ColorPickerComponent', compodocJson, true); + await expect(filteredArgTypes).toMatchFileSnapshot( + join(testDir, 'argtypes-filtered.snapshot') + ); + + expect(filteredArgTypes.color.table?.category).toBe('inputs'); + expect(filteredArgTypes.colorChange).toBeDefined(); + expect((filteredArgTypes.colorChange as any).action).toBe('colorChange'); + expect(filteredArgTypes.showText.table?.category).toBe('inputs'); + expect(filteredArgTypes.showTextChange).toBeDefined(); + expect((filteredArgTypes.showTextChange as any).action).toBe('showTextChange'); + + featureFlags.angularFilterNonInputControls = false; + }); }); }); From 792098b3a422d052a75146ef36b75249c7d09bda Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Tue, 19 May 2026 11:25:25 +0200 Subject: [PATCH 04/17] test(angular): model() signal stories + changelog (cross-cutting) Stacked commit 4 of 4 (cross-cutting) for native @storybook/angular model() signal output detection. Refs: https://github.com/storybookjs/storybook/issues/34831 - AC-X2a: ControlsAndActions story for a model() ColorPickerComponent (color = model('#345F92')) asserting colorChange appears as an Action (fires on emit) and color is a Control/arg. Mirrors the existing Angular signal/ story precedent (template/stories_angular-cli-*/signal/, cli/page.stories.ts play pattern) exactly. - AC-X2b: TwoWayRoundTrip story whose play runs the exact sequence: (1) initial render -> initial args.color reached the instance; (2) live storyProps$ arg change via useArgs().updateArgs -> new value reaches the instance (the StorybookWrapperComponent L125->L131 live path AC-C3c flags highest-risk); (3) in-component colorChange emission round-trips back to args.color (positive two-way [(color)]); (4) action received colorChange. - AC-X3: CHANGELOG.prerelease.md entry under 10.5.0-alpha.0 describing native model() support (type inference + compodoc autodocs + runtime binding/actions) and that the hand-written Args workaround is no longer required. Explicitly documents the KNOWN LIMITATION: aliased model(prop,{alias}) - the type layer (Layer A) can only synthesize ${propName}Change, not the runtime alias; runtime (Layer C) resolves the alias via the Angular component def at AOT. model.required() fully covered. AC-X1 full verification (real output): - yarn nx run-many -t check: angular:check -> "No type errors" (Layers A/B/C clean). Single failing task nextjs-vite:check (TS2451 storybookNextJsPlugin redeclaration) is PRE-EXISTING and unrelated - nextjs-vite is not touched by any of the 4 stacked commits; zero NEW errors from A/B/C/X. - yarn nx run-many -t compile: exit 0 (42 projects). - yarn lint: exit 0 (clean). - yarn fmt:write: applied (4451 files; only the new story files + changelog modified, no unrelated changes). - Targeted Angular vitest from repo root (NgComponentAnalyzer, ComputesTemplateFromComponent, docs/angular-properties, compodoc): 14 files / 137 tests passed, no type errors, exit 0 - zero regression from Layers A/B/C. Story-execution environment constraint (honest, FALLBACK - not a faked green): the storybook-ui Vitest project (cd code && yarn storybook:vitest) is the React-based internal Storybook; its include globs cover only core/** and addons/*/** - frameworks/angular/template/** is not included, so `yarn vitest run --project storybook-ui color-picker.stories` exits 1 with "No test files found". The @storybook/angular unit harness has no AOT Vite plugin and does not glob *.stories.ts. No angular-cli sandbox exists locally. The model() play stories therefore run only in a generated Angular sandbox in CI - the exact established validation path for the pre-existing template/stories_angular-cli-*/signal/ precedent. The stories are authored correctly as real CI/sandbox-validated deliverables. Full detail recorded in .omc/plans/probe-results-angular-model-signal-outputs.md (STEP 4 story-execution environment section). Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.prerelease.md | 16 +++ .../model-signal/color-picker.component.ts | 22 ++++ .../model-signal/color-picker.css | 19 ++++ .../model-signal/color-picker.stories.ts | 101 ++++++++++++++++++ .../model-signal/color-picker.component.ts | 22 ++++ .../model-signal/color-picker.css | 19 ++++ .../model-signal/color-picker.stories.ts | 101 ++++++++++++++++++ 7 files changed, 300 insertions(+) create mode 100644 code/frameworks/angular/template/stories_angular-cli-default-ts/model-signal/color-picker.component.ts create mode 100644 code/frameworks/angular/template/stories_angular-cli-default-ts/model-signal/color-picker.css create mode 100644 code/frameworks/angular/template/stories_angular-cli-default-ts/model-signal/color-picker.stories.ts create mode 100644 code/frameworks/angular/template/stories_angular-cli-prerelease/model-signal/color-picker.component.ts create mode 100644 code/frameworks/angular/template/stories_angular-cli-prerelease/model-signal/color-picker.css create mode 100644 code/frameworks/angular/template/stories_angular-cli-prerelease/model-signal/color-picker.stories.ts diff --git a/CHANGELOG.prerelease.md b/CHANGELOG.prerelease.md index 3e9522148776..f4de55e4b801 100644 --- a/CHANGELOG.prerelease.md +++ b/CHANGELOG.prerelease.md @@ -1,5 +1,21 @@ ## 10.5.0-alpha.0 +- Angular: Add native `model()` signal support - [#34831](https://github.com/storybookjs/storybook/issues/34831), thanks @valentinpalkovic! + + `@storybook/angular` now natively detects Angular `model()` signals across all three layers: + type inference (`Meta`/`StoryObj` argTypes accept the synthesized `${prop}Change` output with + no `TS2353`), Compodoc autodocs (the `${prop}Change` output row is generated), and runtime + binding/actions (the `model()` input is bound and the synthesized output appears in the Actions + panel and supports the two-way `[(prop)]` round-trip). The previously required hand-written + `Args` interface / manual `argTypes` workaround is no longer necessary. `model.required()` is + fully covered. + + Known limitation: for an aliased `model(prop, { alias: 'a' })`, the type layer can only + synthesize `${prop}Change` (`propChange`), not the runtime alias (`aChange`), because + TypeScript cannot see the runtime alias. The runtime layer resolves the alias correctly via + the Angular component definition at AOT, so aliased bindings work at runtime; only the static + type carries the property-name-based `${prop}Change` key. + ## 10.4.0-beta.0 diff --git a/code/frameworks/angular/template/stories_angular-cli-default-ts/model-signal/color-picker.component.ts b/code/frameworks/angular/template/stories_angular-cli-default-ts/model-signal/color-picker.component.ts new file mode 100644 index 000000000000..41de6472fd06 --- /dev/null +++ b/code/frameworks/angular/template/stories_angular-cli-default-ts/model-signal/color-picker.component.ts @@ -0,0 +1,22 @@ +import { Component, model } from '@angular/core'; + +@Component({ + standalone: false, + // Needs a unique selector so it does not clash with other template components + selector: 'storybook-color-picker', + template: `
+ {{ color() }} + +
`, + styleUrls: ['./color-picker.css'], +}) +export default class ColorPickerComponent { + /** + * The currently selected color. + * + * `model()` creates a two-way binding: an input `color` plus a compiler-synthesized + * `colorChange` output. Native `@storybook/angular` `model()` support means this no longer + * requires a hand-written `Args` interface or manual `argTypes` workaround. + */ + color = model('#345F92'); +} diff --git a/code/frameworks/angular/template/stories_angular-cli-default-ts/model-signal/color-picker.css b/code/frameworks/angular/template/stories_angular-cli-default-ts/model-signal/color-picker.css new file mode 100644 index 000000000000..bef353025ac2 --- /dev/null +++ b/code/frameworks/angular/template/stories_angular-cli-default-ts/model-signal/color-picker.css @@ -0,0 +1,19 @@ +div { + display: inline-flex; + align-items: center; + gap: 8px; + font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; +} +span { + font-weight: 700; +} +button { + cursor: pointer; + border: 0; + border-radius: 3em; + padding: 8px 16px; + font-size: 12px; + font-weight: 700; + background-color: #555ab9; + color: white; +} diff --git a/code/frameworks/angular/template/stories_angular-cli-default-ts/model-signal/color-picker.stories.ts b/code/frameworks/angular/template/stories_angular-cli-default-ts/model-signal/color-picker.stories.ts new file mode 100644 index 000000000000..da490687b7d4 --- /dev/null +++ b/code/frameworks/angular/template/stories_angular-cli-default-ts/model-signal/color-picker.stories.ts @@ -0,0 +1,101 @@ +import type { Meta, StoryObj } from '@storybook/angular'; + +import { useArgs } from 'storybook/preview-api'; +import { expect, fn, userEvent, within } from 'storybook/test'; + +import ColorPickerComponent from './color-picker.component'; + +/** + * These stories exercise native `@storybook/angular` support for Angular's `model()` signal. + * + * `color = model('#345F92')` produces a two-way binding: an input `color` plus a + * compiler-synthesized `colorChange` output. Storybook now surfaces `color` as a Control and + * `colorChange` as an Action automatically — no hand-written `Args` interface / manual + * `argTypes` workaround is required (contrast with the `signal/` template stories, whose + * comments note Compodoc does not support signal inputs/outputs). + */ +const meta: Meta = { + component: ColorPickerComponent, + tags: ['autodocs'], + // Use `fn` to spy on the `colorChange` arg, which will appear in the actions panel once + // the synthesized `model()` output emits: https://storybook.js.org/docs/essentials/actions + args: { + colorChange: fn(), + }, +}; + +export default meta; + +type Story = StoryObj; + +/** + * AC-X2a — `colorChange` appears as an Action (fires on emit) and `color` is a Control/arg. + * + * `play`: + * 1. asserts the initial `color` arg rendered (it reached the component instance); + * 2. emits `colorChange` from inside the component and asserts the action spy received it. + */ +export const ControlsAndActions: Story = { + args: { + color: '#345F92', + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + // `color` is a Control/arg: its value reached the component instance and rendered. + await expect(canvas.getByTestId('current-color')).toHaveTextContent('#345F92'); + + // Emitting the synthesized `model()` output (`colorChange`) fires the Action. + await userEvent.click(canvas.getByTestId('emit-green')); + await expect(args.colorChange).toHaveBeenCalledWith('#00FF00'); + }, +}; + +/** + * AC-X2b — positive two-way `[(color)]` round-trip AND the live `storyProps$` update path. + * + * `play` runs the exact sequence: + * 1. initial render → assert the initial `args.color` reached the component instance; + * 2. trigger a LIVE `storyProps$` arg change via `updateArgs` → assert the new value reaches + * the instance (the StorybookWrapperComponent L125→L131 live-update path that AC-C3c flags + * as the highest-risk path now that `color` is an Input); + * 3. trigger an in-component `colorChange` emission → assert it round-trips back to + * `args.color` (positive two-way `[(color)]`); + * 4. assert the action received `colorChange`. + */ +export const TwoWayRoundTrip: Story = { + args: { + color: '#345F92', + }, + render: (args) => { + const [, updateArgs] = useArgs(); + return { + props: { + ...args, + // Two-way `[(color)]`: an emitted `colorChange` writes back to the `color` arg. + colorChange: (value: string) => { + args.colorChange?.(value); + updateArgs({ color: value }); + }, + }, + }; + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const [, updateArgs] = useArgs(); + + // 1. Initial render: the initial `args.color` reached the component instance. + await expect(canvas.getByTestId('current-color')).toHaveTextContent('#345F92'); + + // 2. Live `storyProps$` arg change via Controls/args update reaches the instance. + updateArgs({ color: '#FF0000' }); + await expect(canvas.getByTestId('current-color')).toHaveTextContent('#FF0000'); + + // 3. In-component `colorChange` emission round-trips back to `args.color`. + await userEvent.click(canvas.getByTestId('emit-green')); + await expect(canvas.getByTestId('current-color')).toHaveTextContent('#00FF00'); + + // 4. The action received `colorChange`. + await expect(args.colorChange).toHaveBeenCalledWith('#00FF00'); + }, +}; diff --git a/code/frameworks/angular/template/stories_angular-cli-prerelease/model-signal/color-picker.component.ts b/code/frameworks/angular/template/stories_angular-cli-prerelease/model-signal/color-picker.component.ts new file mode 100644 index 000000000000..41de6472fd06 --- /dev/null +++ b/code/frameworks/angular/template/stories_angular-cli-prerelease/model-signal/color-picker.component.ts @@ -0,0 +1,22 @@ +import { Component, model } from '@angular/core'; + +@Component({ + standalone: false, + // Needs a unique selector so it does not clash with other template components + selector: 'storybook-color-picker', + template: `
+ {{ color() }} + +
`, + styleUrls: ['./color-picker.css'], +}) +export default class ColorPickerComponent { + /** + * The currently selected color. + * + * `model()` creates a two-way binding: an input `color` plus a compiler-synthesized + * `colorChange` output. Native `@storybook/angular` `model()` support means this no longer + * requires a hand-written `Args` interface or manual `argTypes` workaround. + */ + color = model('#345F92'); +} diff --git a/code/frameworks/angular/template/stories_angular-cli-prerelease/model-signal/color-picker.css b/code/frameworks/angular/template/stories_angular-cli-prerelease/model-signal/color-picker.css new file mode 100644 index 000000000000..bef353025ac2 --- /dev/null +++ b/code/frameworks/angular/template/stories_angular-cli-prerelease/model-signal/color-picker.css @@ -0,0 +1,19 @@ +div { + display: inline-flex; + align-items: center; + gap: 8px; + font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; +} +span { + font-weight: 700; +} +button { + cursor: pointer; + border: 0; + border-radius: 3em; + padding: 8px 16px; + font-size: 12px; + font-weight: 700; + background-color: #555ab9; + color: white; +} diff --git a/code/frameworks/angular/template/stories_angular-cli-prerelease/model-signal/color-picker.stories.ts b/code/frameworks/angular/template/stories_angular-cli-prerelease/model-signal/color-picker.stories.ts new file mode 100644 index 000000000000..da490687b7d4 --- /dev/null +++ b/code/frameworks/angular/template/stories_angular-cli-prerelease/model-signal/color-picker.stories.ts @@ -0,0 +1,101 @@ +import type { Meta, StoryObj } from '@storybook/angular'; + +import { useArgs } from 'storybook/preview-api'; +import { expect, fn, userEvent, within } from 'storybook/test'; + +import ColorPickerComponent from './color-picker.component'; + +/** + * These stories exercise native `@storybook/angular` support for Angular's `model()` signal. + * + * `color = model('#345F92')` produces a two-way binding: an input `color` plus a + * compiler-synthesized `colorChange` output. Storybook now surfaces `color` as a Control and + * `colorChange` as an Action automatically — no hand-written `Args` interface / manual + * `argTypes` workaround is required (contrast with the `signal/` template stories, whose + * comments note Compodoc does not support signal inputs/outputs). + */ +const meta: Meta = { + component: ColorPickerComponent, + tags: ['autodocs'], + // Use `fn` to spy on the `colorChange` arg, which will appear in the actions panel once + // the synthesized `model()` output emits: https://storybook.js.org/docs/essentials/actions + args: { + colorChange: fn(), + }, +}; + +export default meta; + +type Story = StoryObj; + +/** + * AC-X2a — `colorChange` appears as an Action (fires on emit) and `color` is a Control/arg. + * + * `play`: + * 1. asserts the initial `color` arg rendered (it reached the component instance); + * 2. emits `colorChange` from inside the component and asserts the action spy received it. + */ +export const ControlsAndActions: Story = { + args: { + color: '#345F92', + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + // `color` is a Control/arg: its value reached the component instance and rendered. + await expect(canvas.getByTestId('current-color')).toHaveTextContent('#345F92'); + + // Emitting the synthesized `model()` output (`colorChange`) fires the Action. + await userEvent.click(canvas.getByTestId('emit-green')); + await expect(args.colorChange).toHaveBeenCalledWith('#00FF00'); + }, +}; + +/** + * AC-X2b — positive two-way `[(color)]` round-trip AND the live `storyProps$` update path. + * + * `play` runs the exact sequence: + * 1. initial render → assert the initial `args.color` reached the component instance; + * 2. trigger a LIVE `storyProps$` arg change via `updateArgs` → assert the new value reaches + * the instance (the StorybookWrapperComponent L125→L131 live-update path that AC-C3c flags + * as the highest-risk path now that `color` is an Input); + * 3. trigger an in-component `colorChange` emission → assert it round-trips back to + * `args.color` (positive two-way `[(color)]`); + * 4. assert the action received `colorChange`. + */ +export const TwoWayRoundTrip: Story = { + args: { + color: '#345F92', + }, + render: (args) => { + const [, updateArgs] = useArgs(); + return { + props: { + ...args, + // Two-way `[(color)]`: an emitted `colorChange` writes back to the `color` arg. + colorChange: (value: string) => { + args.colorChange?.(value); + updateArgs({ color: value }); + }, + }, + }; + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const [, updateArgs] = useArgs(); + + // 1. Initial render: the initial `args.color` reached the component instance. + await expect(canvas.getByTestId('current-color')).toHaveTextContent('#345F92'); + + // 2. Live `storyProps$` arg change via Controls/args update reaches the instance. + updateArgs({ color: '#FF0000' }); + await expect(canvas.getByTestId('current-color')).toHaveTextContent('#FF0000'); + + // 3. In-component `colorChange` emission round-trips back to `args.color`. + await userEvent.click(canvas.getByTestId('emit-green')); + await expect(canvas.getByTestId('current-color')).toHaveTextContent('#00FF00'); + + // 4. The action received `colorChange`. + await expect(args.colorChange).toHaveBeenCalledWith('#00FF00'); + }, +}; From 332fe516887b43b708e7738511f8a9d3a18a3c30 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Tue, 19 May 2026 11:42:24 +0200 Subject: [PATCH 05/17] refactor(angular): address review feedback for model() detection (docs, comments, limitations) comment-only repoint to committed compodoc-input.json fixture; document same-name @Input/@Output discriminator false-positive (compodoc.ts + changelog); synthesized ${name}Change output no longer inherits misleading input defaultValue/type summary (Docs-table only, detection logic unchanged); reference https://github.com/storybookjs/storybook/issues/34831. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.prerelease.md | 9 +++- .../angular-beta/utils/NgComponentAnalyzer.ts | 5 +- .../frameworks/angular/src/client/compodoc.ts | 48 +++++++++++++------ .../doc-model/argtypes-filtered.snapshot | 10 +--- .../doc-model/argtypes.snapshot | 10 +--- .../client/docs/angular-properties.test.ts | 10 ++-- 6 files changed, 53 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.prerelease.md b/CHANGELOG.prerelease.md index f4de55e4b801..522b19a3ea0e 100644 --- a/CHANGELOG.prerelease.md +++ b/CHANGELOG.prerelease.md @@ -10,11 +10,16 @@ `Args` interface / manual `argTypes` workaround is no longer necessary. `model.required()` is fully covered. - Known limitation: for an aliased `model(prop, { alias: 'a' })`, the type layer can only + Known limitations: (1) for an aliased `model(prop, { alias: 'a' })`, the type layer can only synthesize `${prop}Change` (`propChange`), not the runtime alias (`aChange`), because TypeScript cannot see the runtime alias. The runtime layer resolves the alias correctly via the Angular component definition at AOT, so aliased bindings work at runtime; only the static - type carries the property-name-based `${prop}Change` key. + type carries the property-name-based `${prop}Change` key. (2) Compodoc autodocs detect + `model()` via a name-in-both-`inputsClass`-and-`outputsClass` heuristic (compodoc emits no + `model()` marker), so a developer-authored same-name `@Input() x` + `@Output() x` pair is + misclassified as a `model()` (its bare-name output suppressed, a spurious `${x}Change` + synthesized). This is the accepted trade-off for version-tolerant detection through an + external, unpinned tool. ## 10.4.0-beta.0 diff --git a/code/frameworks/angular/src/client/angular-beta/utils/NgComponentAnalyzer.ts b/code/frameworks/angular/src/client/angular-beta/utils/NgComponentAnalyzer.ts index d88d72d4ecc2..9692c08e2f5b 100644 --- a/code/frameworks/angular/src/client/angular-beta/utils/NgComponentAnalyzer.ts +++ b/code/frameworks/angular/src/client/angular-beta/utils/NgComponentAnalyzer.ts @@ -109,8 +109,9 @@ const hasEntry = ( * in `ɵReflectionCapabilities.propMetadata`, so without this path Storybook never binds * them at runtime nor wires up the `xChange` action. * - * Two complementary strategies are used (see - * `.omc/plans/probe-results-angular-model-signal-outputs.md`, Probe C): + * Two complementary strategies are used (the `model()` compodoc shape this mirrors + * is captured in the committed evidence fixture + * `code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/compodoc-input.json`): * * 1. Primary — read the Angular component definition (`ɵcmp` via `ɵgetComponentDef`). * At real AOT runtime (the Angular builder used by Storybook/sandboxes) `ɵcmp.inputs` diff --git a/code/frameworks/angular/src/client/compodoc.ts b/code/frameworks/angular/src/client/compodoc.ts index a5aa8a36f110..f49bad9008ba 100644 --- a/code/frameworks/angular/src/client/compodoc.ts +++ b/code/frameworks/angular/src/client/compodoc.ts @@ -241,15 +241,26 @@ export const extractArgTypesFromData = (componentData: Class | Directive | Injec // Detect Angular `model()` signals. // - // compodoc (verified against the captured v1.2.1 output at - // `.omc/plans/probe-fixtures/compodoc-model-probe-documentation.json`) emits a - // `model()` member as an IDENTICAL entry — same bare name, e.g. `color` — in BOTH - // `inputsClass` AND `outputsClass`, with no `decorators`/`jsdoctags` and the - // `ModelSignal` wrapper erased to the unwrapped value type. Plain - // `@Input`/`input()` only land in `inputsClass`; plain `@Output`/`output()`/ - // `EventEmitter` only land in `outputsClass` (and never under the input's name). - // The only reliable, version-tolerant discriminator is therefore a property whose - // name appears in BOTH arrays of the same component. + // compodoc (verified against the captured v1.2.1 output committed at + // `code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/compodoc-input.json`, + // byte-identical to the real Probe B capture) emits a `model()` member as an + // IDENTICAL entry — same bare name, e.g. `color` — in BOTH `inputsClass` AND + // `outputsClass`, with no `decorators`/`jsdoctags` and the `ModelSignal` + // wrapper erased to the unwrapped value type. Plain `@Input`/`input()` only land + // in `inputsClass`; plain `@Output`/`output()`/`EventEmitter` only land in + // `outputsClass` (and never under the input's name). The only reliable, + // version-tolerant discriminator is therefore a property whose name appears in + // BOTH arrays of the same component. + // + // Known limitation: this both-arrays heuristic also matches a developer-authored + // same-name pair — an `@Input() x` together with an `@Output() x`, or an + // `@Input() set foo()` together with an `@Output() foo` — because compodoc has no + // `model()` marker to distinguish that hand-written pair from a real `model()`. + // Such a pair is misclassified as a `model()`: its bare-name output is suppressed + // below and a spurious `${name}Change` output is synthesized. This is an accepted + // limitation of detecting `model()` through an external, unpinned tool that emits + // no `model()` marker (per Probe B); a same-name `@Input`/`@Output` pair is rare + // and is the documented trade-off for version-tolerant detection. const inputClassNames = new Set( (((componentData as any).inputsClass as Property[]) || []).map((item) => item.name) ); @@ -267,7 +278,8 @@ export const extractArgTypesFromData = (componentData: Class | Directive | Injec // The model property must surface as an INPUT control (via its `inputsClass` // entry); the corresponding output is the synthesized `${name}Change` added // below — not a plain bare-name output. See the model() detection note above - // (`.omc/plans/probe-fixtures/compodoc-model-probe-documentation.json`). + // (committed evidence fixture + // `code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/compodoc-input.json`). if (key === 'outputsClass' && !isMethod(item) && modelPropertyNames.has(item.name)) { return; } @@ -316,11 +328,20 @@ export const extractArgTypesFromData = (componentData: Class | Directive | Injec // - flag ON: iteration is restricted to `['inputsClass']` (filter L227-229), so // the model input control still surfaces, and `${name}Change` is re-surfaced // here despite `outputsClass` never being iterated. - // Evidence basis: `.omc/plans/probe-fixtures/compodoc-model-probe-documentation.json`. + // Evidence basis: committed fixture + // `code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/compodoc-input.json`. modelProperties.forEach((item) => { const changeName = `${item.name}Change`; - const defaultValue = extractDefaultValue(item); + // The synthesized member is an OUTPUT (an `EventEmitter`-equivalent), not the + // model INPUT it is derived from. It must therefore NOT inherit the input's + // Docs metadata: an event has no default value, and its `table.type.summary` + // should read like an output handler signature rather than the input value + // type. So we OMIT `defaultValue` and render the type as + // `(e: ${item.type}) => void` (the model value type as the emitted payload), + // matching how genuine `@Output`/`output()` members surface in the Docs table. + // This is Docs-table cosmetic only — Controls/Actions wiring (the `action` + // field below) and the both-arrays detection are unchanged. const argType = { name: changeName, description: item.rawdescription || item.description, @@ -329,10 +350,9 @@ export const extractArgTypesFromData = (componentData: Class | Directive | Injec table: { category: 'outputs', type: { - summary: item.type, + summary: `(e: ${item.type}) => void`, required: !item.optional, }, - defaultValue: { summary: defaultValue }, }, }; diff --git a/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/argtypes-filtered.snapshot b/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/argtypes-filtered.snapshot index a6d20e8a8d05..d77122d74876 100644 --- a/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/argtypes-filtered.snapshot +++ b/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/argtypes-filtered.snapshot @@ -22,12 +22,9 @@ "name": "colorChange", "table": { "category": "outputs", - "defaultValue": { - "summary": "#345F92", - }, "type": { "required": true, - "summary": "string", + "summary": "(e: string) => void", }, }, "type": { @@ -58,12 +55,9 @@ "name": "showTextChange", "table": { "category": "outputs", - "defaultValue": { - "summary": false, - }, "type": { "required": true, - "summary": "boolean", + "summary": "(e: boolean) => void", }, }, "type": { diff --git a/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/argtypes.snapshot b/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/argtypes.snapshot index a6d20e8a8d05..d77122d74876 100644 --- a/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/argtypes.snapshot +++ b/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/argtypes.snapshot @@ -22,12 +22,9 @@ "name": "colorChange", "table": { "category": "outputs", - "defaultValue": { - "summary": "#345F92", - }, "type": { "required": true, - "summary": "string", + "summary": "(e: string) => void", }, }, "type": { @@ -58,12 +55,9 @@ "name": "showTextChange", "table": { "category": "outputs", - "defaultValue": { - "summary": false, - }, "type": { "required": true, - "summary": "boolean", + "summary": "(e: boolean) => void", }, }, "type": { diff --git a/code/frameworks/angular/src/client/docs/angular-properties.test.ts b/code/frameworks/angular/src/client/docs/angular-properties.test.ts index 50e4c0cdcf1b..765472abbfd8 100644 --- a/code/frameworks/angular/src/client/docs/angular-properties.test.ts +++ b/code/frameworks/angular/src/client/docs/angular-properties.test.ts @@ -50,11 +50,11 @@ describe('angular component properties', () => { // compodoc is an external, unpinned tool that is not a repo dependency, so it // cannot be invoked from the unit-test harness. Fixtures that ship a captured, - // parseable `compodoc-input.json` (the `model()` case captured by Probe B at - // `.omc/plans/probe-fixtures/compodoc-model-probe-documentation.json`) get the - // real `extractArgTypesFromData` assertions; legacy fixtures without it (which - // would require re-running compodoc) keep a trivial green test so they are not - // regressed. + // parseable `compodoc-input.json` (e.g. the `model()` case in + // `__testfixtures__/doc-model/compodoc-input.json`, byte-identical to the real + // compodoc v1.2.1 output) get the real `extractArgTypesFromData` assertions; + // legacy fixtures without it (which would require re-running compodoc) keep a + // trivial green test so they are not regressed. const hasCapturedCompodocJson = dirEntries.includes('compodoc-input.json'); if (!hasCapturedCompodocJson) { it(`${testEntry.name} (compodoc capture not available)`, () => { From a74018a97bff59ef46e84e1c7977a3fc9bb0e969 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Tue, 19 May 2026 12:53:20 +0200 Subject: [PATCH 06/17] =?UTF-8?q?refactor(angular):=20remove=20component?= =?UTF-8?q?=20instantiation=20from=20analyzer;=20static=20=C9=B5cmp=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback (Karpathy + Pragmatic): - NgComponentAnalyzer: drop the new-instance fallback (runInInjectionContext + new component()) and its orphaned imports. Signal I/O is now read purely statically from the compiled component definition (ɵcmp), so the analysis path never executes user constructor code. Verified the fallback was test-harness-only (ComputesTemplateFromComponent/compodoc/angular-properties unaffected). - Tests: assert the production ɵcmp reader via a synthetic ɵcmp mirroring the AOT shape (now also covers aliased model() resolution). Drop the over-engineered throws-if-called resolveComponentFactory guard; restore pre-existing imports. - Strip internal investigation jargon (Probe/AC/R6/Lxxx) from comments in NgComponentAnalyzer, compodoc, public-types, and the model-signal stories. - CHANGELOG: collapse to the repo one-line convention. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.prerelease.md | 20 --- .../utils/NgComponentAnalyzer.test.ts | 139 ++++++----------- .../angular-beta/utils/NgComponentAnalyzer.ts | 144 +++++------------- .../frameworks/angular/src/client/compodoc.ts | 72 +++------ .../angular/src/client/public-types.ts | 12 +- .../model-signal/color-picker.stories.ts | 13 +- .../model-signal/color-picker.stories.ts | 13 +- 7 files changed, 124 insertions(+), 289 deletions(-) diff --git a/CHANGELOG.prerelease.md b/CHANGELOG.prerelease.md index 522b19a3ea0e..511b9d321871 100644 --- a/CHANGELOG.prerelease.md +++ b/CHANGELOG.prerelease.md @@ -2,26 +2,6 @@ - Angular: Add native `model()` signal support - [#34831](https://github.com/storybookjs/storybook/issues/34831), thanks @valentinpalkovic! - `@storybook/angular` now natively detects Angular `model()` signals across all three layers: - type inference (`Meta`/`StoryObj` argTypes accept the synthesized `${prop}Change` output with - no `TS2353`), Compodoc autodocs (the `${prop}Change` output row is generated), and runtime - binding/actions (the `model()` input is bound and the synthesized output appears in the Actions - panel and supports the two-way `[(prop)]` round-trip). The previously required hand-written - `Args` interface / manual `argTypes` workaround is no longer necessary. `model.required()` is - fully covered. - - Known limitations: (1) for an aliased `model(prop, { alias: 'a' })`, the type layer can only - synthesize `${prop}Change` (`propChange`), not the runtime alias (`aChange`), because - TypeScript cannot see the runtime alias. The runtime layer resolves the alias correctly via - the Angular component definition at AOT, so aliased bindings work at runtime; only the static - type carries the property-name-based `${prop}Change` key. (2) Compodoc autodocs detect - `model()` via a name-in-both-`inputsClass`-and-`outputsClass` heuristic (compodoc emits no - `model()` marker), so a developer-authored same-name `@Input() x` + `@Output() x` pair is - misclassified as a `model()` (its bare-name output suppressed, a spurious `${x}Change` - synthesized). This is the accepted trade-off for version-tolerant detection through an - external, unpinned tool. - - ## 10.4.0-beta.0 - CLI: Handle minimumReleaseAge conflicts across package managers - [#34769](https://github.com/storybookjs/storybook/pull/34769), thanks @JReinhold! diff --git a/code/frameworks/angular/src/client/angular-beta/utils/NgComponentAnalyzer.test.ts b/code/frameworks/angular/src/client/angular-beta/utils/NgComponentAnalyzer.test.ts index ab84f80345d2..18734f41358d 100644 --- a/code/frameworks/angular/src/client/angular-beta/utils/NgComponentAnalyzer.test.ts +++ b/code/frameworks/angular/src/client/angular-beta/utils/NgComponentAnalyzer.test.ts @@ -3,11 +3,8 @@ import type { Type } from '@angular/core'; import { Component, - // `ComponentFactoryResolver` is the abstract symbol the (now-commented) factory - // test path used via `TestBed.inject(ComponentFactoryResolver)`. It is still - // exported in Angular 22; we use it only as a throws-if-called decouple guard - // (its `resolveComponentFactory` method is abstract / not on the prototype). - ComponentFactoryResolver, + // Removed in Angular 22 + // ComponentFactoryResolver, Directive, EventEmitter, HostBinding, @@ -16,12 +13,11 @@ import { Output, Pipe, input, - model, output, } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { BrowserDynamicTestingModule } from '@angular/platform-browser-dynamic/testing'; -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { getComponentInputsOutputs, @@ -51,11 +47,9 @@ describe('getComponentInputsOutputs', () => { }); }); - // TODO(angular-22): re-enable factory-comparison assertions when ComponentFactoryResolver path is resolved - // Every `it()` in the block below asserts against `resolveComponentFactory(...)`, which relies on the - // Angular-22-removed `ComponentFactoryResolver`. There is no separable non-factory half, so these stay - // commented; the live `describe('getComponentInputsOutputs (non-factory)')` block further down provides - // equivalent factory-free coverage (incl. `model()`). See + // TODO(angular-22): these assert against `resolveComponentFactory(...)`, removed in + // Angular 22. Equivalent factory-free coverage (incl. `model()`) lives in the + // `getComponentInputsOutputs (signal-based I/O)` block below. See // https://github.com/storybookjs/storybook/issues/34831 /* Commented out until we figure out how to handle the removal of ComponentFactoryResolver in Angular 22 See https://github.com/angular/angular/releases/tag/v22.0.0-next.7 @@ -222,12 +216,23 @@ describe('getComponentInputsOutputs', () => { */ }); -describe('getComponentInputsOutputs (non-factory)', () => { - // These assertions never CALL `resolveComponentFactory` / a `ComponentFactoryResolver` - // instance to derive I/O (the Angular-22-affected factory path the commented block - // above relied on). They cover the same I/O detection surface as that block, plus - // `model()` signal detection, using literal expected shapes only. `ComponentFactoryResolver` - // is imported solely by the final test as a throws-if-called decouple guard. +describe('getComponentInputsOutputs (signal-based I/O)', () => { + // `input()`/`output()`/`model()` are decorator-less, so `getComponentInputsOutputs` + // reads them from the compiled Angular component definition (`ɵcmp`). The Angular + // builder always emits `ɵcmp` for the components Storybook receives, but this unit + // harness (analog vite-plugin-angular) leaves `ɵcmp.inputs`/`ɵcmp.outputs` empty for + // signal members, so we attach a synthetic `ɵcmp` mirroring the documented AOT shape + // and assert the production reader. Full end-to-end signal detection at real AOT is + // covered by the `model-signal` sandbox play stories in CI. + // + // AOT `ɵcmp` shape: + // inputs: { [templateName]: [propName, flags] } (signal inputs are arrays) + // outputs: { [templateName]: propName } + const withCmp = (inputs: Record, outputs: Record) => { + class FooComponent {} + (FooComponent as any).ɵcmp = { inputs, outputs }; + return FooComponent; + }; it('detects @Input / @Output (decorator path, unchanged)', () => { @Component({ template: '', standalone: false }) @@ -258,21 +263,7 @@ describe('getComponentInputsOutputs (non-factory)', () => { ); }); - it('detects input() / output() signal members', () => { - @Component({ template: '', standalone: true }) - class FooComponent { - public signalInput = input(); - - public signalOutput = output(); - } - - const { inputs, outputs } = getComponentInputsOutputs(FooComponent); - - expect(inputs).toContainEqual({ propName: 'signalInput', templateName: 'signalInput' }); - expect(outputs).toContainEqual({ propName: 'signalOutput', templateName: 'signalOutput' }); - }); - - it('detects EventEmitter @Output', () => { + it('detects EventEmitter @Output (decorator path, unchanged)', () => { @Component({ template: '', standalone: true }) class FooComponent { @Output() public emitter = new EventEmitter(); @@ -283,78 +274,38 @@ describe('getComponentInputsOutputs (non-factory)', () => { expect(outputs).toContainEqual({ propName: 'emitter', templateName: 'emitter' }); }); - it('detects model() as both an input and a synthesized `${name}Change` output', () => { - @Component({ template: '', standalone: true }) - class FooComponent { - public color = model(); + it('detects input() / output() signal members from ɵcmp', () => { + const FooComponent = withCmp( + { signalInput: ['signalInput', 1] }, + { signalOutput: 'signalOutput' } + ); - public reqd = model.required(); + const { inputs, outputs } = getComponentInputsOutputs(FooComponent); - public aliased = model(undefined, { alias: 'al' }); - } + expect(inputs).toContainEqual({ propName: 'signalInput', templateName: 'signalInput' }); + expect(outputs).toContainEqual({ propName: 'signalOutput', templateName: 'signalOutput' }); + }); + + it('detects model() as an input plus its synthesized `${name}Change` output', () => { + // `color = model()`, `reqd = model.required()`, `aliased = model(_, { alias: 'al' })`. + // The Angular compiler resolves the alias in `ɵcmp`, so the input is keyed by the + // binding name (`al`) and the synthesized output by `${alias}Change` (`alChange`). + const FooComponent = withCmp( + { color: ['color', 1], reqd: ['reqd', 1], al: ['aliased', 1] }, + { colorChange: 'color', reqdChange: 'reqd', alChange: 'aliased' } + ); const { inputs, outputs } = getComponentInputsOutputs(FooComponent); - // model() field surfaces BOTH the `color` input AND a `colorChange` output. expect(inputs).toContainEqual({ propName: 'color', templateName: 'color' }); expect(outputs).toContainEqual({ propName: 'color', templateName: 'colorChange' }); - // model.required() behaves identically. expect(inputs).toContainEqual({ propName: 'reqd', templateName: 'reqd' }); expect(outputs).toContainEqual({ propName: 'reqd', templateName: 'reqdChange' }); - // Aliased model(): the runtime alias (`al`/`alChange`) is only resolvable via - // `ɵcmp` at real AOT runtime. In this JIT/esbuild unit-test harness `ɵcmp` is - // empty for signal members (Probe C), so the bespoke fallback synthesizes from - // the property name (`aliased`/`aliasedChange`). This is the documented - // harness-only fallback shape, not a regression: at AOT the primary - // `ɵgetComponentDef` path yields the resolved `al`/`alChange` names. - expect(inputs.some((i) => i.propName === 'aliased')).toBe(true); - expect(outputs.some((o) => o.propName === 'aliased' && o.templateName.endsWith('Change'))).toBe( - true - ); - }); - - it('never invokes resolveComponentFactory for model() detection', () => { - // R6 decouple proof (PREFERRED mechanism — throws-if-called guard). - // - // The live `@angular/core` ESM namespace is non-extensible and exports no - // top-level `resolveComponentFactory` (it was only ever a method on the - // abstract `ComponentFactoryResolver`, the symbol the now-commented factory - // test path consumed via `TestBed.inject(ComponentFactoryResolver)`). - // `ComponentFactoryResolver` is still exported and its prototype IS - // extensible (the `resolveComponentFactory` method is abstract, so absent - // from the prototype). We install a throws-if-called `resolveComponentFactory` - // on that prototype: if the runtime model() path had ANY residual - // `ComponentFactoryResolver` coupling it would invoke this and throw. The - // detection completing while the guard is never called proves the path is - // purely instance/component-def based. - const proto = ComponentFactoryResolver.prototype as unknown as Record; - const hadOwn = Object.prototype.hasOwnProperty.call(proto, 'resolveComponentFactory'); - const original = proto.resolveComponentFactory; - const throwIfCalled = vi.fn(() => { - throw new Error('resolveComponentFactory must not be invoked by model() detection'); - }); - try { - proto.resolveComponentFactory = throwIfCalled; - - @Component({ template: '', standalone: true }) - class FooComponent { - public color = model(); - } - - const { inputs, outputs } = getComponentInputsOutputs(FooComponent); - - expect(inputs).toContainEqual({ propName: 'color', templateName: 'color' }); - expect(outputs).toContainEqual({ propName: 'color', templateName: 'colorChange' }); - expect(throwIfCalled).not.toHaveBeenCalled(); - } finally { - if (hadOwn) { - proto.resolveComponentFactory = original; - } else { - delete proto.resolveComponentFactory; - } - } + // Aliased model(): the resolved binding name (`al`/`alChange`) flows through. + expect(inputs).toContainEqual({ propName: 'aliased', templateName: 'al' }); + expect(outputs).toContainEqual({ propName: 'aliased', templateName: 'alChange' }); }); }); diff --git a/code/frameworks/angular/src/client/angular-beta/utils/NgComponentAnalyzer.ts b/code/frameworks/angular/src/client/angular-beta/utils/NgComponentAnalyzer.ts index 9692c08e2f5b..318b869f6cf0 100644 --- a/code/frameworks/angular/src/client/angular-beta/utils/NgComponentAnalyzer.ts +++ b/code/frameworks/angular/src/client/angular-beta/utils/NgComponentAnalyzer.ts @@ -2,13 +2,9 @@ import type { Type } from '@angular/core'; import { Component, Directive, - Injector, Input, - OutputEmitterRef, Output, Pipe, - isSignal, - runInInjectionContext, ɵReflectionCapabilities as ReflectionCapabilities, ɵgetComponentDef as getComponentDef, } from '@angular/core'; @@ -90,8 +86,8 @@ export const getComponentInputsOutputs = (component: any): ComponentInputsOutput // Additively surface signal-based I/O (`input()`, `output()`, `model()`), which carry // no decorator metadata and are therefore invisible to the decorator path above. - // This is intentionally additive and never mutates the decorator-derived results, so - // `@Input`/`@Output`/`EventEmitter` behavior is unchanged (zero regression). + // This is additive and never mutates the decorator-derived results, so + // `@Input`/`@Output`/`EventEmitter` behavior is unchanged. return addSignalInputsOutputs(component, decoratorDerived); }; @@ -102,30 +98,21 @@ const hasEntry = ( ) => list.some((e) => e.propName === propName || e.templateName === templateName); /** - * Surfaces signal-based I/O that the decorator-reflection path cannot see. + * Surfaces signal-based I/O (`input()`, `output()`, `model()`) that the + * decorator-reflection path cannot see. * - * Angular's `model()` lowers to a binding pair: an `x` input + a compiler-synthesized - * `xChange` output. `input()`/`output()` are likewise decorator-less. None of these appear - * in `ɵReflectionCapabilities.propMetadata`, so without this path Storybook never binds - * them at runtime nor wires up the `xChange` action. + * Angular's `model()` lowers to a binding pair: an `x` input plus a + * compiler-synthesized `xChange` output. `input()`/`output()` are likewise + * decorator-less, so none of them appear in `ɵReflectionCapabilities.propMetadata`. * - * Two complementary strategies are used (the `model()` compodoc shape this mirrors - * is captured in the committed evidence fixture - * `code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/compodoc-input.json`): + * They are read instead from the compiled Angular component definition (`ɵcmp` + * via `ɵgetComponentDef`). Storybook only ever receives components compiled by + * the Angular builder, so `ɵcmp.inputs`/`ɵcmp.outputs` already encode the + * resolved binding names — aliased `model(x, { alias })` and `model.required()` + * included. This is purely static: it never instantiates the component. * - * 1. Primary — read the Angular component definition (`ɵcmp` via `ɵgetComponentDef`). - * At real AOT runtime (the Angular builder used by Storybook/sandboxes) `ɵcmp.inputs` - * and `ɵcmp.outputs` already encode the *resolved* binding names, so aliased - * `model(x, { alias })` and `model.required()` are handled correctly here. - * - * 2. Fallback — a `model()`/`input()`/`output()`-aware synthesis from the component - * instance shape. In the `@storybook/angular` JIT/esbuild unit-test harness (and any - * consumer receiving a non-AOT-compiled class) esbuild strips the AOT signal metadata - * and the JIT compiler cannot reflect decorator-less signal members, so `ɵcmp.inputs` - * / `ɵcmp.outputs` are empty for signal members (`ɵcmp.signals === false`). The - * fallback detects the runtime brand of each instance field instead. - * - * Both paths are additive and de-duplicated against the decorator-derived results. + * Results are additive and de-duplicated against the decorator-derived results, + * so `@Input`/`@Output`/`EventEmitter` behavior is unchanged. */ const addSignalInputsOutputs = ( component: any, @@ -136,89 +123,36 @@ const addSignalInputsOutputs = ( outputs: [...base.outputs], }; - // 1. Primary: Angular component definition (resolved binding names, AOT-correct). + let def: any; try { - const def: any = getComponentDef(component); - if (def) { - // Angular's `ɵcmp` def keys the I/O maps by the *template* (public/binding) - // name, NOT the class property name: - // def.inputs: { [templateName]: propName | [propName, flags, transform] } - // def.outputs: { [templateName]: propName } - // (verified empirically; aliased `@Input('a') b` → def.inputs = { a: ['b',…] }). - for (const templateName of Object.keys(def.inputs ?? {})) { - const rawPropName = def.inputs[templateName]; - const propName = Array.isArray(rawPropName) - ? (rawPropName[0] ?? templateName) - : (rawPropName ?? templateName); - if (!hasEntry(result.inputs, propName, templateName)) { - result.inputs.push({ propName, templateName }); - } - } - for (const templateName of Object.keys(def.outputs ?? {})) { - const propName = def.outputs[templateName] ?? templateName; - if (!hasEntry(result.outputs, propName, templateName)) { - result.outputs.push({ propName, templateName }); - } - } - } + def = getComponentDef(component); } catch { - // `ɵgetComponentDef` may be unavailable for non-component classes; ignore. + // `ɵgetComponentDef` may be unavailable for non-component classes. + return result; + } + if (!def) { + return result; } - // 2. Fallback: synthesize from the component instance shape when signal members were - // not surfaced by the component definition (non-AOT/JIT-compiled classes). - try { - let instance: any; - runInInjectionContext(Injector.create({ providers: [] }), () => { - instance = new component(); - }); - - if (instance) { - for (const propName of Object.keys(instance)) { - const member = instance[propName]; - if (member == null) { - continue; - } - - // `isSignal()` narrows `member` to `Signal`, which does not expose the - // writable (`set`/`update`) or subscribable (`subscribe`) members that brand a - // `model()`/`output()` at runtime. Probe those off an un-narrowed reference. - const memberAny = member as any; - const isWritableSignal = - isSignal(member) && - typeof memberAny.set === 'function' && - typeof memberAny.update === 'function'; - const isSubscribable = typeof memberAny.subscribe === 'function'; - - if (isWritableSignal && isSubscribable) { - // `model()` / `model.required()`: input `x` + synthesized output `xChange`. - // The runtime alias is not observable on the instance; the resolved binding - // name is only available via `ɵcmp` (handled by the primary path at AOT). - const changeName = `${propName}Change`; - if (!hasEntry(result.inputs, propName, propName)) { - result.inputs.push({ propName, templateName: propName }); - } - if (!hasEntry(result.outputs, propName, changeName)) { - result.outputs.push({ propName, templateName: changeName }); - } - } else if (isSignal(member)) { - // `input()` / `input.required()`: writable-less signal → input only. - if (!hasEntry(result.inputs, propName, propName)) { - result.inputs.push({ propName, templateName: propName }); - } - } else if (member instanceof OutputEmitterRef) { - // `output()`: not a signal, exposes `subscribe` → output only. - if (!hasEntry(result.outputs, propName, propName)) { - result.outputs.push({ propName, templateName: propName }); - } - } - } + // `ɵcmp` keys the I/O maps by the *template* (public/binding) name, not the + // class property name: + // def.inputs: { [templateName]: propName | [propName, flags, transform] } + // def.outputs: { [templateName]: propName } + // (aliased `@Input('a') b` → def.inputs = { a: ['b', …] }). + for (const templateName of Object.keys(def.inputs ?? {})) { + const rawPropName = def.inputs[templateName]; + const propName = Array.isArray(rawPropName) + ? (rawPropName[0] ?? templateName) + : (rawPropName ?? templateName); + if (!hasEntry(result.inputs, propName, templateName)) { + result.inputs.push({ propName, templateName }); + } + } + for (const templateName of Object.keys(def.outputs ?? {})) { + const propName = def.outputs[templateName] ?? templateName; + if (!hasEntry(result.outputs, propName, templateName)) { + result.outputs.push({ propName, templateName }); } - } catch { - // The component may not be instantiable outside its real DI context (e.g. it - // requires constructor dependencies). The primary path above already covers the - // AOT runtime case; failing instantiation here is non-fatal and just means no - // extra fallback-derived signal members are added. } return result; diff --git a/code/frameworks/angular/src/client/compodoc.ts b/code/frameworks/angular/src/client/compodoc.ts index f49bad9008ba..8c6c2ac00df2 100644 --- a/code/frameworks/angular/src/client/compodoc.ts +++ b/code/frameworks/angular/src/client/compodoc.ts @@ -241,26 +241,17 @@ export const extractArgTypesFromData = (componentData: Class | Directive | Injec // Detect Angular `model()` signals. // - // compodoc (verified against the captured v1.2.1 output committed at - // `code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/compodoc-input.json`, - // byte-identical to the real Probe B capture) emits a `model()` member as an - // IDENTICAL entry — same bare name, e.g. `color` — in BOTH `inputsClass` AND - // `outputsClass`, with no `decorators`/`jsdoctags` and the `ModelSignal` - // wrapper erased to the unwrapped value type. Plain `@Input`/`input()` only land - // in `inputsClass`; plain `@Output`/`output()`/`EventEmitter` only land in - // `outputsClass` (and never under the input's name). The only reliable, - // version-tolerant discriminator is therefore a property whose name appears in - // BOTH arrays of the same component. + // compodoc emits no `model()` marker: a `model()` member appears as the same bare + // name in BOTH `inputsClass` AND `outputsClass`, whereas plain `@Input`/`input()` + // only land in `inputsClass` and plain `@Output`/`output()` only in `outputsClass`. + // A name present in both arrays is therefore the only version-tolerant discriminator. + // (See the committed fixture under `__testfixtures__/doc-model/`.) // - // Known limitation: this both-arrays heuristic also matches a developer-authored - // same-name pair — an `@Input() x` together with an `@Output() x`, or an - // `@Input() set foo()` together with an `@Output() foo` — because compodoc has no - // `model()` marker to distinguish that hand-written pair from a real `model()`. - // Such a pair is misclassified as a `model()`: its bare-name output is suppressed - // below and a spurious `${name}Change` output is synthesized. This is an accepted - // limitation of detecting `model()` through an external, unpinned tool that emits - // no `model()` marker (per Probe B); a same-name `@Input`/`@Output` pair is rare - // and is the documented trade-off for version-tolerant detection. + // Known limitation: a developer-authored same-name `@Input() x` + `@Output() x` + // pair is indistinguishable from a real `model()` and is misclassified — its + // bare-name output is suppressed and a spurious `${name}Change` is synthesized. + // This is the accepted trade-off for detecting `model()` through an external, + // unpinned tool; such a pair is rare. const inputClassNames = new Set( (((componentData as any).inputsClass as Property[]) || []).map((item) => item.name) ); @@ -274,12 +265,9 @@ export const extractArgTypesFromData = (componentData: Class | Directive | Injec data.forEach((item: Method | Property) => { const section = mapItemToSection(key, item); - // Suppress compodoc's spurious `outputsClass` duplicate of a `model()` property. - // The model property must surface as an INPUT control (via its `inputsClass` - // entry); the corresponding output is the synthesized `${name}Change` added - // below — not a plain bare-name output. See the model() detection note above - // (committed evidence fixture - // `code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/compodoc-input.json`). + // Suppress compodoc's spurious bare-name `outputsClass` duplicate of a + // `model()`. The model surfaces as an INPUT control (from `inputsClass`); its + // output is the synthesized `${name}Change` added below. if (key === 'outputsClass' && !isMethod(item) && modelPropertyNames.has(item.name)) { return; } @@ -314,34 +302,18 @@ export const extractArgTypesFromData = (componentData: Class | Directive | Injec }); }); - // Synthesize the `${name}Change` output for every detected `model()` property. - // - // compodoc does NOT emit a `${name}Change` member (it merely duplicates the - // property under its bare name into `outputsClass`), so Storybook synthesizes the - // two-way `${name}Change` output here, reusing the per-item output shape above. - // - // This runs unconditionally, AFTER the iteration loop, so it is deterministic - // across both `FEATURES.angularFilterNonInputControls` states: - // - flag OFF: the model input control comes from `inputsClass`; the spurious - // bare-name `outputsClass` duplicate is suppressed above; `${name}Change` is - // added here. - // - flag ON: iteration is restricted to `['inputsClass']` (filter L227-229), so - // the model input control still surfaces, and `${name}Change` is re-surfaced - // here despite `outputsClass` never being iterated. - // Evidence basis: committed fixture - // `code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/compodoc-input.json`. + // Synthesize the `${name}Change` output for every detected `model()`. compodoc + // never emits it. This runs after the iteration loop so it is deterministic + // regardless of `FEATURES.angularFilterNonInputControls` (which restricts the + // loop to `inputsClass`): the model input control still comes from `inputsClass` + // and `${name}Change` is added here either way. modelProperties.forEach((item) => { const changeName = `${item.name}Change`; - // The synthesized member is an OUTPUT (an `EventEmitter`-equivalent), not the - // model INPUT it is derived from. It must therefore NOT inherit the input's - // Docs metadata: an event has no default value, and its `table.type.summary` - // should read like an output handler signature rather than the input value - // type. So we OMIT `defaultValue` and render the type as - // `(e: ${item.type}) => void` (the model value type as the emitted payload), - // matching how genuine `@Output`/`output()` members surface in the Docs table. - // This is Docs-table cosmetic only — Controls/Actions wiring (the `action` - // field below) and the both-arrays detection are unchanged. + // The synthesized member is an OUTPUT, not the model INPUT it derives from, so + // it must not inherit the input's Docs metadata: omit `defaultValue` and render + // the type as the emitted-payload handler signature, matching how genuine + // `@Output`/`output()` members appear in the Docs table. const argType = { name: changeName, description: item.rawdescription || item.description, diff --git a/code/frameworks/angular/src/client/public-types.ts b/code/frameworks/angular/src/client/public-types.ts index e993e7a739c8..2d24b772f74b 100644 --- a/code/frameworks/angular/src/client/public-types.ts +++ b/code/frameworks/angular/src/client/public-types.ts @@ -111,12 +111,12 @@ type TransformOutputSignalType = { * additionally synthesizes an intersection member keyed `` `${K}Change` `` typed * `(e: E) => void`. * - * Known limitation (documented for the AC-X3 changelog): aliased - * `model(prop, { alias: 'a' })` produces `aChange` at runtime, but the type - * layer can only synthesize `${propName}Change` (`propChange`) because - * TypeScript cannot observe the runtime alias. Runtime detection (Layer C, via - * the resolved binding name on `ɵcmp`) still handles aliasing correctly. - * `model.required()` has the same shape as `model()` and is fully covered. + * Known limitation: aliased `model(prop, { alias: 'a' })` produces `aChange` at + * runtime, but the type layer can only synthesize `${propName}Change` + * (`propChange`) because TypeScript cannot observe the runtime alias. Runtime + * detection (via the resolved binding name on `ɵcmp`) still handles aliasing + * correctly. `model.required()` has the same shape as `model()` and is fully + * covered. */ type TransformModelSignalType = { [K in keyof T]: T[K] extends ModelSignal ? E : T[K]; diff --git a/code/frameworks/angular/template/stories_angular-cli-default-ts/model-signal/color-picker.stories.ts b/code/frameworks/angular/template/stories_angular-cli-default-ts/model-signal/color-picker.stories.ts index da490687b7d4..a41279360341 100644 --- a/code/frameworks/angular/template/stories_angular-cli-default-ts/model-signal/color-picker.stories.ts +++ b/code/frameworks/angular/template/stories_angular-cli-default-ts/model-signal/color-picker.stories.ts @@ -29,7 +29,7 @@ export default meta; type Story = StoryObj; /** - * AC-X2a — `colorChange` appears as an Action (fires on emit) and `color` is a Control/arg. + * `colorChange` appears as an Action (fires on emit) and `color` is a Control/arg. * * `play`: * 1. asserts the initial `color` arg rendered (it reached the component instance); @@ -52,13 +52,12 @@ export const ControlsAndActions: Story = { }; /** - * AC-X2b — positive two-way `[(color)]` round-trip AND the live `storyProps$` update path. + * Positive two-way `[(color)]` round-trip plus the live args-update path. * - * `play` runs the exact sequence: + * `play` runs the sequence: * 1. initial render → assert the initial `args.color` reached the component instance; - * 2. trigger a LIVE `storyProps$` arg change via `updateArgs` → assert the new value reaches - * the instance (the StorybookWrapperComponent L125→L131 live-update path that AC-C3c flags - * as the highest-risk path now that `color` is an Input); + * 2. trigger a live arg change via `updateArgs` → assert the new value reaches the + * instance (the highest-risk path now that `color` is an Input); * 3. trigger an in-component `colorChange` emission → assert it round-trips back to * `args.color` (positive two-way `[(color)]`); * 4. assert the action received `colorChange`. @@ -87,7 +86,7 @@ export const TwoWayRoundTrip: Story = { // 1. Initial render: the initial `args.color` reached the component instance. await expect(canvas.getByTestId('current-color')).toHaveTextContent('#345F92'); - // 2. Live `storyProps$` arg change via Controls/args update reaches the instance. + // 2. A live arg change via Controls/args update reaches the instance. updateArgs({ color: '#FF0000' }); await expect(canvas.getByTestId('current-color')).toHaveTextContent('#FF0000'); diff --git a/code/frameworks/angular/template/stories_angular-cli-prerelease/model-signal/color-picker.stories.ts b/code/frameworks/angular/template/stories_angular-cli-prerelease/model-signal/color-picker.stories.ts index da490687b7d4..a41279360341 100644 --- a/code/frameworks/angular/template/stories_angular-cli-prerelease/model-signal/color-picker.stories.ts +++ b/code/frameworks/angular/template/stories_angular-cli-prerelease/model-signal/color-picker.stories.ts @@ -29,7 +29,7 @@ export default meta; type Story = StoryObj; /** - * AC-X2a — `colorChange` appears as an Action (fires on emit) and `color` is a Control/arg. + * `colorChange` appears as an Action (fires on emit) and `color` is a Control/arg. * * `play`: * 1. asserts the initial `color` arg rendered (it reached the component instance); @@ -52,13 +52,12 @@ export const ControlsAndActions: Story = { }; /** - * AC-X2b — positive two-way `[(color)]` round-trip AND the live `storyProps$` update path. + * Positive two-way `[(color)]` round-trip plus the live args-update path. * - * `play` runs the exact sequence: + * `play` runs the sequence: * 1. initial render → assert the initial `args.color` reached the component instance; - * 2. trigger a LIVE `storyProps$` arg change via `updateArgs` → assert the new value reaches - * the instance (the StorybookWrapperComponent L125→L131 live-update path that AC-C3c flags - * as the highest-risk path now that `color` is an Input); + * 2. trigger a live arg change via `updateArgs` → assert the new value reaches the + * instance (the highest-risk path now that `color` is an Input); * 3. trigger an in-component `colorChange` emission → assert it round-trips back to * `args.color` (positive two-way `[(color)]`); * 4. assert the action received `colorChange`. @@ -87,7 +86,7 @@ export const TwoWayRoundTrip: Story = { // 1. Initial render: the initial `args.color` reached the component instance. await expect(canvas.getByTestId('current-color')).toHaveTextContent('#345F92'); - // 2. Live `storyProps$` arg change via Controls/args update reaches the instance. + // 2. A live arg change via Controls/args update reaches the instance. updateArgs({ color: '#FF0000' }); await expect(canvas.getByTestId('current-color')).toHaveTextContent('#FF0000'); From 2f86d33a7601ba2f9aaa56653741baa54fcc2eb3 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Tue, 19 May 2026 13:17:11 +0200 Subject: [PATCH 07/17] docs(angular): trim redundant comments in model() detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cut comments that restated the code or repeated the rationale across call site and JSDoc; kept the load-bearing ones (ɵcmp shape, type-composition order, compodoc both-arrays heuristic + its limitation). Co-Authored-By: Claude Opus 4.7 --- .../utils/NgComponentAnalyzer.test.ts | 14 +++----- .../angular-beta/utils/NgComponentAnalyzer.ts | 31 +++++------------ .../frameworks/angular/src/client/compodoc.ts | 33 +++++++------------ .../angular/src/client/public-types.ts | 18 ++++------ 4 files changed, 31 insertions(+), 65 deletions(-) diff --git a/code/frameworks/angular/src/client/angular-beta/utils/NgComponentAnalyzer.test.ts b/code/frameworks/angular/src/client/angular-beta/utils/NgComponentAnalyzer.test.ts index 18734f41358d..405c378841bd 100644 --- a/code/frameworks/angular/src/client/angular-beta/utils/NgComponentAnalyzer.test.ts +++ b/code/frameworks/angular/src/client/angular-beta/utils/NgComponentAnalyzer.test.ts @@ -217,16 +217,10 @@ describe('getComponentInputsOutputs', () => { }); describe('getComponentInputsOutputs (signal-based I/O)', () => { - // `input()`/`output()`/`model()` are decorator-less, so `getComponentInputsOutputs` - // reads them from the compiled Angular component definition (`ɵcmp`). The Angular - // builder always emits `ɵcmp` for the components Storybook receives, but this unit - // harness (analog vite-plugin-angular) leaves `ɵcmp.inputs`/`ɵcmp.outputs` empty for - // signal members, so we attach a synthetic `ɵcmp` mirroring the documented AOT shape - // and assert the production reader. Full end-to-end signal detection at real AOT is - // covered by the `model-signal` sandbox play stories in CI. - // - // AOT `ɵcmp` shape: - // inputs: { [templateName]: [propName, flags] } (signal inputs are arrays) + // The unit harness leaves `ɵcmp` empty for signal members, so we attach a + // synthetic `ɵcmp` in the AOT shape and assert the production reader. Real + // end-to-end signal detection is covered by the `model-signal` sandbox stories. + // inputs: { [templateName]: [propName, flags] } // outputs: { [templateName]: propName } const withCmp = (inputs: Record, outputs: Record) => { class FooComponent {} diff --git a/code/frameworks/angular/src/client/angular-beta/utils/NgComponentAnalyzer.ts b/code/frameworks/angular/src/client/angular-beta/utils/NgComponentAnalyzer.ts index 318b869f6cf0..d69354c094ba 100644 --- a/code/frameworks/angular/src/client/angular-beta/utils/NgComponentAnalyzer.ts +++ b/code/frameworks/angular/src/client/angular-beta/utils/NgComponentAnalyzer.ts @@ -84,10 +84,7 @@ export const getComponentInputsOutputs = (component: any): ComponentInputsOutput return previousValue; }, initialValue); - // Additively surface signal-based I/O (`input()`, `output()`, `model()`), which carry - // no decorator metadata and are therefore invisible to the decorator path above. - // This is additive and never mutates the decorator-derived results, so - // `@Input`/`@Output`/`EventEmitter` behavior is unchanged. + // Add signal-based I/O, which the decorator path above cannot see. return addSignalInputsOutputs(component, decoratorDerived); }; @@ -98,21 +95,13 @@ const hasEntry = ( ) => list.some((e) => e.propName === propName || e.templateName === templateName); /** - * Surfaces signal-based I/O (`input()`, `output()`, `model()`) that the - * decorator-reflection path cannot see. - * - * Angular's `model()` lowers to a binding pair: an `x` input plus a - * compiler-synthesized `xChange` output. `input()`/`output()` are likewise - * decorator-less, so none of them appear in `ɵReflectionCapabilities.propMetadata`. - * - * They are read instead from the compiled Angular component definition (`ɵcmp` - * via `ɵgetComponentDef`). Storybook only ever receives components compiled by - * the Angular builder, so `ɵcmp.inputs`/`ɵcmp.outputs` already encode the - * resolved binding names — aliased `model(x, { alias })` and `model.required()` - * included. This is purely static: it never instantiates the component. - * - * Results are additive and de-duplicated against the decorator-derived results, - * so `@Input`/`@Output`/`EventEmitter` behavior is unchanged. + * `model()`/`input()`/`output()` are decorator-less, so they never appear in + * `ɵReflectionCapabilities.propMetadata`. They are read instead from the compiled + * component definition (`ɵcmp`), which Storybook always receives from the Angular + * builder and which already encodes resolved binding names (aliased + * `model(x, { alias })` and `model.required()` included). Purely static — never + * instantiates the component. Results are additive and de-duplicated against + * `base`, so decorator-based I/O is unchanged. */ const addSignalInputsOutputs = ( component: any, @@ -134,11 +123,9 @@ const addSignalInputsOutputs = ( return result; } - // `ɵcmp` keys the I/O maps by the *template* (public/binding) name, not the - // class property name: + // `ɵcmp` keys the I/O maps by template (binding) name, not property name: // def.inputs: { [templateName]: propName | [propName, flags, transform] } // def.outputs: { [templateName]: propName } - // (aliased `@Input('a') b` → def.inputs = { a: ['b', …] }). for (const templateName of Object.keys(def.inputs ?? {})) { const rawPropName = def.inputs[templateName]; const propName = Array.isArray(rawPropName) diff --git a/code/frameworks/angular/src/client/compodoc.ts b/code/frameworks/angular/src/client/compodoc.ts index 8c6c2ac00df2..6c622a841427 100644 --- a/code/frameworks/angular/src/client/compodoc.ts +++ b/code/frameworks/angular/src/client/compodoc.ts @@ -239,19 +239,15 @@ export const extractArgTypesFromData = (componentData: Class | Directive | Injec | 'inputsClass' | 'outputsClass'; - // Detect Angular `model()` signals. + // Detect Angular `model()` signals. compodoc emits no `model()` marker: a + // `model()` lands under the same bare name in BOTH `inputsClass` and + // `outputsClass`, whereas plain inputs/outputs land in only one. A name in both + // arrays is the only version-tolerant discriminator. // - // compodoc emits no `model()` marker: a `model()` member appears as the same bare - // name in BOTH `inputsClass` AND `outputsClass`, whereas plain `@Input`/`input()` - // only land in `inputsClass` and plain `@Output`/`output()` only in `outputsClass`. - // A name present in both arrays is therefore the only version-tolerant discriminator. - // (See the committed fixture under `__testfixtures__/doc-model/`.) - // - // Known limitation: a developer-authored same-name `@Input() x` + `@Output() x` - // pair is indistinguishable from a real `model()` and is misclassified — its - // bare-name output is suppressed and a spurious `${name}Change` is synthesized. - // This is the accepted trade-off for detecting `model()` through an external, - // unpinned tool; such a pair is rare. + // Known limitation: a hand-written same-name `@Input() x` + `@Output() x` pair is + // indistinguishable from a real `model()` and is misclassified (bare-name output + // suppressed, spurious `${name}Change` synthesized). Rare; accepted trade-off for + // an external, unpinned tool. const inputClassNames = new Set( (((componentData as any).inputsClass as Property[]) || []).map((item) => item.name) ); @@ -302,18 +298,13 @@ export const extractArgTypesFromData = (componentData: Class | Directive | Injec }); }); - // Synthesize the `${name}Change` output for every detected `model()`. compodoc - // never emits it. This runs after the iteration loop so it is deterministic - // regardless of `FEATURES.angularFilterNonInputControls` (which restricts the - // loop to `inputsClass`): the model input control still comes from `inputsClass` - // and `${name}Change` is added here either way. + // Synthesize the `${name}Change` output compodoc never emits. Runs after the + // loop so it is unaffected by `FEATURES.angularFilterNonInputControls`. modelProperties.forEach((item) => { const changeName = `${item.name}Change`; - // The synthesized member is an OUTPUT, not the model INPUT it derives from, so - // it must not inherit the input's Docs metadata: omit `defaultValue` and render - // the type as the emitted-payload handler signature, matching how genuine - // `@Output`/`output()` members appear in the Docs table. + // This is an OUTPUT, not the model INPUT it derives from: omit `defaultValue` + // and render the type as the emitted-payload handler signature. const argType = { name: changeName, description: item.rawdescription || item.description, diff --git a/code/frameworks/angular/src/client/public-types.ts b/code/frameworks/angular/src/client/public-types.ts index 2d24b772f74b..2b2652f9d8f4 100644 --- a/code/frameworks/angular/src/client/public-types.ts +++ b/code/frameworks/angular/src/client/public-types.ts @@ -103,20 +103,14 @@ type TransformOutputSignalType = { /** * Angular `model()` generates a binding pair: an input `x: T` plus a - * compiler-synthesized output `xChange: (e: T) => void`. The `xChange` member is - * NOT a real class member, so it can never be discovered by iterating - * `keyof T`; it must be synthesized here. - * - * This type maps every `ModelSignal` field `K` to its value type `E`, and - * additionally synthesizes an intersection member keyed `` `${K}Change` `` typed - * `(e: E) => void`. + * compiler-synthesized output `xChange: (e: T) => void`. `xChange` is not a real + * class member, so it cannot be found by iterating `keyof T` and is synthesized + * here as an intersection member. * * Known limitation: aliased `model(prop, { alias: 'a' })` produces `aChange` at - * runtime, but the type layer can only synthesize `${propName}Change` - * (`propChange`) because TypeScript cannot observe the runtime alias. Runtime - * detection (via the resolved binding name on `ɵcmp`) still handles aliasing - * correctly. `model.required()` has the same shape as `model()` and is fully - * covered. + * runtime, but the type layer can only synthesize `${propName}Change` because + * TypeScript cannot observe the runtime alias. Runtime detection (via `ɵcmp`) + * still handles aliasing correctly. `model.required()` is fully covered. */ type TransformModelSignalType = { [K in keyof T]: T[K] extends ModelSignal ? E : T[K]; From 19175ef710ef7ddc19de9be2a82119cb1723dcf3 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Tue, 19 May 2026 13:34:52 +0200 Subject: [PATCH 08/17] docs(angular): trim transcript prose, drop changelog entry - Remove the CHANGELOG.prerelease.md entry (reverts to next). - public-types.test-d.ts: drop "Layer A"/"AC-X3" transcript prose, tighten the transform-input comment. - public-types.ts: condense the TransformComponentType JSDoc to the load-bearing do-NOT-reorder constraint. - compodoc.ts: broaden the model() false-positive note (inherited / accessor splits) and document the aliased-model() autodocs gap. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.prerelease.md | 1 - .../frameworks/angular/src/client/compodoc.ts | 18 +++++++---- .../angular/src/client/public-types.test-d.ts | 31 ++++++------------- .../angular/src/client/public-types.ts | 16 ++++------ 4 files changed, 28 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.prerelease.md b/CHANGELOG.prerelease.md index 511b9d321871..3e9522148776 100644 --- a/CHANGELOG.prerelease.md +++ b/CHANGELOG.prerelease.md @@ -1,6 +1,5 @@ ## 10.5.0-alpha.0 -- Angular: Add native `model()` signal support - [#34831](https://github.com/storybookjs/storybook/issues/34831), thanks @valentinpalkovic! ## 10.4.0-beta.0 diff --git a/code/frameworks/angular/src/client/compodoc.ts b/code/frameworks/angular/src/client/compodoc.ts index 6c622a841427..5086923fe7e2 100644 --- a/code/frameworks/angular/src/client/compodoc.ts +++ b/code/frameworks/angular/src/client/compodoc.ts @@ -241,13 +241,19 @@ export const extractArgTypesFromData = (componentData: Class | Directive | Injec // Detect Angular `model()` signals. compodoc emits no `model()` marker: a // `model()` lands under the same bare name in BOTH `inputsClass` and - // `outputsClass`, whereas plain inputs/outputs land in only one. A name in both - // arrays is the only version-tolerant discriminator. + // `outputsClass`, whereas plain inputs/outputs land in only one. A name in + // both arrays is the only version-tolerant discriminator. // - // Known limitation: a hand-written same-name `@Input() x` + `@Output() x` pair is - // indistinguishable from a real `model()` and is misclassified (bare-name output - // suppressed, spurious `${name}Change` synthesized). Rare; accepted trade-off for - // an external, unpinned tool. + // Known limitations (accepted trade-offs for an external, unpinned tool that + // emits no `model()` marker; both affect autodocs only): + // - Any same-name input/output pair is misclassified as a `model()` — a + // hand-written `@Input() x` + `@Output() x`, an inherited member surfacing + // in both arrays, or an accessor/property split — suppressing the bare + // output and synthesizing a spurious `${name}Change`. + // - Aliased `model(prop, { alias })` is keyed by compodoc under the class + // property name, so the synthesized output is `${propName}Change`, not the + // runtime `${alias}Change`. Runtime detection (`ɵcmp`) resolves the alias + // correctly. const inputClassNames = new Set( (((componentData as any).inputsClass as Property[]) || []).map((item) => item.name) ); diff --git a/code/frameworks/angular/src/client/public-types.test-d.ts b/code/frameworks/angular/src/client/public-types.test-d.ts index 666fdd14637b..dae67b530e77 100644 --- a/code/frameworks/angular/src/client/public-types.test-d.ts +++ b/code/frameworks/angular/src/client/public-types.test-d.ts @@ -5,20 +5,13 @@ import { EventEmitter, Input, Output, input, model, numberAttribute, output } fr import type { TransformComponentType } from './public-types.ts'; /** - * Layer A (type inference) regression suite for Angular `model()` signal - * outputs. + * Type-inference coverage for Angular `model()` signal outputs, asserted on the + * composed `TransformComponentType` alongside the existing + * input()/output()/EventEmitter/@Input/@Output channels to guard regressions. * - * Assertions are made on the FINAL composed `TransformComponentType` (NOT - * `TransformModelSignalType` in isolation), proving the pinned innermost - * composition resolves the synthesized `${prop}Change` key and the model value - * field SIMULTANEOUSLY in one composed type, with zero regression to the - * existing `input()` / `output()` / `EventEmitter` / `@Input` / `@Output` - * channels. - * - * Known limitation (also recorded for the AC-X3 changelog): aliased - * `model(prop, { alias: 'a' })` produces `aChange` at runtime, but Layer A can - * only synthesize `${propName}Change`. Runtime detection (Layer C) handles the - * alias correctly via the resolved binding name on `ɵcmp`. + * Aliased `model(prop, { alias })` is a known gap: the type layer can only + * synthesize `${propName}Change` because TypeScript cannot observe the runtime + * alias. Runtime detection via `ɵcmp` resolves the alias correctly. */ class C { color = model(); @@ -33,7 +26,7 @@ class C { type Transformed = TransformComponentType; -describe('TransformComponentType — model() signal outputs (Layer A)', () => { +describe('TransformComponentType — model() signal outputs', () => { it('maps a model() field to its value type and synthesizes ${prop}Change', () => { expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf<(e: string) => void>(); @@ -49,13 +42,9 @@ describe('TransformComponentType — model() signal outputs (Layer A)', () => { }); it('does not regress transform input() signal inputs', () => { - // Pre-existing, unchanged behavior of `TransformInputSignalType`: it - // extracts the WRITE/transform-input type `U` from - // `InputSignalWithTransform`. `numberAttribute` has signature - // `(value: unknown) => number`, so the signal is - // `InputSignalWithTransform` and the transform surfaces - // `unknown` (the accepted bound input). Layer A does not alter this; this - // assertion pins the no-regression baseline. + // `numberAttribute` types the signal as `InputSignalWithTransform`, so `TransformInputSignalType` surfaces the accepted input type + // `unknown`. Unchanged by model() support; pins the no-regression baseline. expectTypeOf().toEqualTypeOf(); }); diff --git a/code/frameworks/angular/src/client/public-types.ts b/code/frameworks/angular/src/client/public-types.ts index 2b2652f9d8f4..ca30188a4419 100644 --- a/code/frameworks/angular/src/client/public-types.ts +++ b/code/frameworks/angular/src/client/public-types.ts @@ -53,17 +53,13 @@ export type StoryContext = GenericStoryContext; /** - * Utility type that transforms InputSignal, ModelSignal, OutputEmitterRef and - * EventEmitter types. + * Transforms InputSignal, ModelSignal, OutputEmitterRef and EventEmitter member + * types into the values/handlers Storybook args expect. * - * Composition is pinned (do NOT reorder): `TransformModelSignalType` is the - * INNERMOST wrapper so that (a) the synthesized `${K}Change` key is created - * before the outer transforms run and passes through them unchanged (it is - * `(e: E) => void`, which matches none of the Input/Output/Event extends - * clauses), and (b) since `ModelSignal extends InputSignal`, the model's - * value field — after `TransformModelSignalType` maps it to `E` — is - * idempotently re-collapsed by the outer `TransformInputSignalType` to the same - * `E` (no double-transform divergence). + * Do NOT reorder: `TransformModelSignalType` must stay innermost. It synthesizes + * the `${K}Change` output key before the outer transforms run, and because + * `ModelSignal extends InputSignal` the model value field is then + * idempotently re-collapsed by `TransformInputSignalType` to the same type. */ export type TransformComponentType = TransformInputSignalType< TransformOutputSignalType>> From ee48639f553718db8989495fff26f726d020135d Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Tue, 19 May 2026 15:01:02 +0200 Subject: [PATCH 09/17] fix(angular): don't call useArgs() inside model-signal play `useArgs()` is a preview hook and throws when called in a `play` function ("hooks can only be called inside decorators and story functions"), which crashed the TwoWayRoundTrip story and failed Chromatic with 1 component error. Update args from `play` via the channel (UPDATE_STORY_ARGS + STORY_RENDERED), matching the existing core/svelte precedent, and tag the story `!vitest` since live arg updates disrupt the runner. The valid render-side useArgs() write-back is unchanged. Co-Authored-By: Claude Opus 4.7 --- .../model-signal/color-picker.stories.ts | 27 ++++++++++++------- .../model-signal/color-picker.stories.ts | 27 ++++++++++++------- 2 files changed, 36 insertions(+), 18 deletions(-) diff --git a/code/frameworks/angular/template/stories_angular-cli-default-ts/model-signal/color-picker.stories.ts b/code/frameworks/angular/template/stories_angular-cli-default-ts/model-signal/color-picker.stories.ts index a41279360341..6b76697be797 100644 --- a/code/frameworks/angular/template/stories_angular-cli-default-ts/model-signal/color-picker.stories.ts +++ b/code/frameworks/angular/template/stories_angular-cli-default-ts/model-signal/color-picker.stories.ts @@ -1,7 +1,9 @@ import type { Meta, StoryObj } from '@storybook/angular'; -import { useArgs } from 'storybook/preview-api'; -import { expect, fn, userEvent, within } from 'storybook/test'; +import { STORY_RENDERED, UPDATE_STORY_ARGS } from 'storybook/internal/core-events'; + +import { addons, useArgs } from 'storybook/preview-api'; +import { expect, fn, userEvent, waitFor, within } from 'storybook/test'; import ColorPickerComponent from './color-picker.component'; @@ -56,13 +58,15 @@ export const ControlsAndActions: Story = { * * `play` runs the sequence: * 1. initial render → assert the initial `args.color` reached the component instance; - * 2. trigger a live arg change via `updateArgs` → assert the new value reaches the + * 2. push a live arg change over the channel → assert the new value reaches the * instance (the highest-risk path now that `color` is an Input); * 3. trigger an in-component `colorChange` emission → assert it round-trips back to * `args.color` (positive two-way `[(color)]`); * 4. assert the action received `colorChange`. */ export const TwoWayRoundTrip: Story = { + // Live args updates inside `play` disrupt the Vitest runner; sandbox + Chromatic cover it. + tags: ['!vitest'], args: { color: '#345F92', }, @@ -79,20 +83,25 @@ export const TwoWayRoundTrip: Story = { }, }; }, - play: async ({ canvasElement, args }) => { + play: async ({ canvasElement, args, id }) => { const canvas = within(canvasElement); - const [, updateArgs] = useArgs(); + const channel = addons.getChannel(); // 1. Initial render: the initial `args.color` reached the component instance. await expect(canvas.getByTestId('current-color')).toHaveTextContent('#345F92'); - // 2. A live arg change via Controls/args update reaches the instance. - updateArgs({ color: '#FF0000' }); - await expect(canvas.getByTestId('current-color')).toHaveTextContent('#FF0000'); + // 2. A live arg change (Controls/args update) reaches the Input. + channel.emit(UPDATE_STORY_ARGS, { storyId: id, updatedArgs: { color: '#FF0000' } }); + await new Promise((resolve) => channel.once(STORY_RENDERED, resolve)); + await waitFor(async () => { + await expect(canvas.getByTestId('current-color')).toHaveTextContent('#FF0000'); + }); // 3. In-component `colorChange` emission round-trips back to `args.color`. await userEvent.click(canvas.getByTestId('emit-green')); - await expect(canvas.getByTestId('current-color')).toHaveTextContent('#00FF00'); + await waitFor(async () => { + await expect(canvas.getByTestId('current-color')).toHaveTextContent('#00FF00'); + }); // 4. The action received `colorChange`. await expect(args.colorChange).toHaveBeenCalledWith('#00FF00'); diff --git a/code/frameworks/angular/template/stories_angular-cli-prerelease/model-signal/color-picker.stories.ts b/code/frameworks/angular/template/stories_angular-cli-prerelease/model-signal/color-picker.stories.ts index a41279360341..6b76697be797 100644 --- a/code/frameworks/angular/template/stories_angular-cli-prerelease/model-signal/color-picker.stories.ts +++ b/code/frameworks/angular/template/stories_angular-cli-prerelease/model-signal/color-picker.stories.ts @@ -1,7 +1,9 @@ import type { Meta, StoryObj } from '@storybook/angular'; -import { useArgs } from 'storybook/preview-api'; -import { expect, fn, userEvent, within } from 'storybook/test'; +import { STORY_RENDERED, UPDATE_STORY_ARGS } from 'storybook/internal/core-events'; + +import { addons, useArgs } from 'storybook/preview-api'; +import { expect, fn, userEvent, waitFor, within } from 'storybook/test'; import ColorPickerComponent from './color-picker.component'; @@ -56,13 +58,15 @@ export const ControlsAndActions: Story = { * * `play` runs the sequence: * 1. initial render → assert the initial `args.color` reached the component instance; - * 2. trigger a live arg change via `updateArgs` → assert the new value reaches the + * 2. push a live arg change over the channel → assert the new value reaches the * instance (the highest-risk path now that `color` is an Input); * 3. trigger an in-component `colorChange` emission → assert it round-trips back to * `args.color` (positive two-way `[(color)]`); * 4. assert the action received `colorChange`. */ export const TwoWayRoundTrip: Story = { + // Live args updates inside `play` disrupt the Vitest runner; sandbox + Chromatic cover it. + tags: ['!vitest'], args: { color: '#345F92', }, @@ -79,20 +83,25 @@ export const TwoWayRoundTrip: Story = { }, }; }, - play: async ({ canvasElement, args }) => { + play: async ({ canvasElement, args, id }) => { const canvas = within(canvasElement); - const [, updateArgs] = useArgs(); + const channel = addons.getChannel(); // 1. Initial render: the initial `args.color` reached the component instance. await expect(canvas.getByTestId('current-color')).toHaveTextContent('#345F92'); - // 2. A live arg change via Controls/args update reaches the instance. - updateArgs({ color: '#FF0000' }); - await expect(canvas.getByTestId('current-color')).toHaveTextContent('#FF0000'); + // 2. A live arg change (Controls/args update) reaches the Input. + channel.emit(UPDATE_STORY_ARGS, { storyId: id, updatedArgs: { color: '#FF0000' } }); + await new Promise((resolve) => channel.once(STORY_RENDERED, resolve)); + await waitFor(async () => { + await expect(canvas.getByTestId('current-color')).toHaveTextContent('#FF0000'); + }); // 3. In-component `colorChange` emission round-trips back to `args.color`. await userEvent.click(canvas.getByTestId('emit-green')); - await expect(canvas.getByTestId('current-color')).toHaveTextContent('#00FF00'); + await waitFor(async () => { + await expect(canvas.getByTestId('current-color')).toHaveTextContent('#00FF00'); + }); // 4. The action received `colorChange`. await expect(args.colorChange).toHaveBeenCalledWith('#00FF00'); From 436e31ca4c151c40da8ffbca3910f391a293376c Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Tue, 19 May 2026 15:32:31 +0200 Subject: [PATCH 10/17] Apply suggestion from @valentinpalkovic --- code/frameworks/angular/src/client/public-types.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/code/frameworks/angular/src/client/public-types.ts b/code/frameworks/angular/src/client/public-types.ts index ca30188a4419..8d68416d46bb 100644 --- a/code/frameworks/angular/src/client/public-types.ts +++ b/code/frameworks/angular/src/client/public-types.ts @@ -97,17 +97,6 @@ type TransformOutputSignalType = { [K in keyof T]: T[K] extends OutputEmitterRef ? (e: E) => void : T[K]; }; -/** - * Angular `model()` generates a binding pair: an input `x: T` plus a - * compiler-synthesized output `xChange: (e: T) => void`. `xChange` is not a real - * class member, so it cannot be found by iterating `keyof T` and is synthesized - * here as an intersection member. - * - * Known limitation: aliased `model(prop, { alias: 'a' })` produces `aChange` at - * runtime, but the type layer can only synthesize `${propName}Change` because - * TypeScript cannot observe the runtime alias. Runtime detection (via `ɵcmp`) - * still handles aliasing correctly. `model.required()` is fully covered. - */ type TransformModelSignalType = { [K in keyof T]: T[K] extends ModelSignal ? E : T[K]; } & { From 5b66f0f76f2743967dae1d2438f3f9bcc1cc03f9 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Tue, 19 May 2026 15:34:41 +0200 Subject: [PATCH 11/17] Apply suggestion from @valentinpalkovic --- code/frameworks/angular/src/client/compodoc.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/code/frameworks/angular/src/client/compodoc.ts b/code/frameworks/angular/src/client/compodoc.ts index 5086923fe7e2..5e17513fbcb8 100644 --- a/code/frameworks/angular/src/client/compodoc.ts +++ b/code/frameworks/angular/src/client/compodoc.ts @@ -243,17 +243,6 @@ export const extractArgTypesFromData = (componentData: Class | Directive | Injec // `model()` lands under the same bare name in BOTH `inputsClass` and // `outputsClass`, whereas plain inputs/outputs land in only one. A name in // both arrays is the only version-tolerant discriminator. - // - // Known limitations (accepted trade-offs for an external, unpinned tool that - // emits no `model()` marker; both affect autodocs only): - // - Any same-name input/output pair is misclassified as a `model()` — a - // hand-written `@Input() x` + `@Output() x`, an inherited member surfacing - // in both arrays, or an accessor/property split — suppressing the bare - // output and synthesizing a spurious `${name}Change`. - // - Aliased `model(prop, { alias })` is keyed by compodoc under the class - // property name, so the synthesized output is `${propName}Change`, not the - // runtime `${alias}Change`. Runtime detection (`ɵcmp`) resolves the alias - // correctly. const inputClassNames = new Set( (((componentData as any).inputsClass as Property[]) || []).map((item) => item.name) ); From 1fc92e8521c28cc3a9628ec8973c91d09260ec6b Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Wed, 20 May 2026 08:18:01 +0200 Subject: [PATCH 12/17] Apply suggestion from @valentinpalkovic --- .../client/angular-beta/utils/NgComponentAnalyzer.test.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/code/frameworks/angular/src/client/angular-beta/utils/NgComponentAnalyzer.test.ts b/code/frameworks/angular/src/client/angular-beta/utils/NgComponentAnalyzer.test.ts index 405c378841bd..149bd169be56 100644 --- a/code/frameworks/angular/src/client/angular-beta/utils/NgComponentAnalyzer.test.ts +++ b/code/frameworks/angular/src/client/angular-beta/utils/NgComponentAnalyzer.test.ts @@ -47,11 +47,7 @@ describe('getComponentInputsOutputs', () => { }); }); - // TODO(angular-22): these assert against `resolveComponentFactory(...)`, removed in - // Angular 22. Equivalent factory-free coverage (incl. `model()`) lives in the - // `getComponentInputsOutputs (signal-based I/O)` block below. See - // https://github.com/storybookjs/storybook/issues/34831 - /* Commented out until we figure out how to handle the removal of ComponentFactoryResolver in Angular 22 +/* Commented out until we figure out how to handle the removal of ComponentFactoryResolver in Angular 22 See https://github.com/angular/angular/releases/tag/v22.0.0-next.7 it('should return I/O', () => { From a6d29d04e53b154dca22f4d0a9d5f24c44abace8 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Wed, 20 May 2026 08:19:18 +0200 Subject: [PATCH 13/17] Apply suggestion from @valentinpalkovic --- .../angular/src/client/docs/angular-properties.test.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/code/frameworks/angular/src/client/docs/angular-properties.test.ts b/code/frameworks/angular/src/client/docs/angular-properties.test.ts index 765472abbfd8..4e8164a59c85 100644 --- a/code/frameworks/angular/src/client/docs/angular-properties.test.ts +++ b/code/frameworks/angular/src/client/docs/angular-properties.test.ts @@ -6,11 +6,6 @@ import { describe, expect, it, vi } from 'vitest'; import { extractArgTypesFromData, findComponentByName } from '../compodoc.ts'; import type { CompodocJson } from '../compodoc-types.ts'; -// `compodoc.ts` destructures `FEATURES` from the global scope at module-load time -// (`const { FEATURES } = global`). The destructure captures the OBJECT REFERENCE, so -// the flag object must exist on the global BEFORE the module is imported. `vi.hoisted` -// is lifted above the imports during transform, so this runs first; per-state changes -// are then applied by MUTATING that same object (never reassigning). const featureFlags = vi.hoisted(() => { const flags = { angularFilterNonInputControls: false }; (globalThis as any).FEATURES = flags; From 23634baaa7478ab78cf8e6b7765634e18ac8542b Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Wed, 20 May 2026 08:20:44 +0200 Subject: [PATCH 14/17] Apply suggestions from code review Co-authored-by: Valentin Palkovic --- .../model-signal/color-picker.stories.ts | 27 ------------------- 1 file changed, 27 deletions(-) diff --git a/code/frameworks/angular/template/stories_angular-cli-prerelease/model-signal/color-picker.stories.ts b/code/frameworks/angular/template/stories_angular-cli-prerelease/model-signal/color-picker.stories.ts index 6b76697be797..2fd1a6958c49 100644 --- a/code/frameworks/angular/template/stories_angular-cli-prerelease/model-signal/color-picker.stories.ts +++ b/code/frameworks/angular/template/stories_angular-cli-prerelease/model-signal/color-picker.stories.ts @@ -7,15 +7,6 @@ import { expect, fn, userEvent, waitFor, within } from 'storybook/test'; import ColorPickerComponent from './color-picker.component'; -/** - * These stories exercise native `@storybook/angular` support for Angular's `model()` signal. - * - * `color = model('#345F92')` produces a two-way binding: an input `color` plus a - * compiler-synthesized `colorChange` output. Storybook now surfaces `color` as a Control and - * `colorChange` as an Action automatically — no hand-written `Args` interface / manual - * `argTypes` workaround is required (contrast with the `signal/` template stories, whose - * comments note Compodoc does not support signal inputs/outputs). - */ const meta: Meta = { component: ColorPickerComponent, tags: ['autodocs'], @@ -30,13 +21,6 @@ export default meta; type Story = StoryObj; -/** - * `colorChange` appears as an Action (fires on emit) and `color` is a Control/arg. - * - * `play`: - * 1. asserts the initial `color` arg rendered (it reached the component instance); - * 2. emits `colorChange` from inside the component and asserts the action spy received it. - */ export const ControlsAndActions: Story = { args: { color: '#345F92', @@ -53,17 +37,6 @@ export const ControlsAndActions: Story = { }, }; -/** - * Positive two-way `[(color)]` round-trip plus the live args-update path. - * - * `play` runs the sequence: - * 1. initial render → assert the initial `args.color` reached the component instance; - * 2. push a live arg change over the channel → assert the new value reaches the - * instance (the highest-risk path now that `color` is an Input); - * 3. trigger an in-component `colorChange` emission → assert it round-trips back to - * `args.color` (positive two-way `[(color)]`); - * 4. assert the action received `colorChange`. - */ export const TwoWayRoundTrip: Story = { // Live args updates inside `play` disrupt the Vitest runner; sandbox + Chromatic cover it. tags: ['!vitest'], From 2d047d3276ad356ce57cee9883341aadb96b323c Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Wed, 20 May 2026 08:22:52 +0200 Subject: [PATCH 15/17] Apply suggestions from code review Co-authored-by: Valentin Palkovic --- .../model-signal/color-picker.component.ts | 7 ----- .../model-signal/color-picker.stories.ts | 27 ------------------- .../model-signal/color-picker.component.ts | 7 ----- 3 files changed, 41 deletions(-) diff --git a/code/frameworks/angular/template/stories_angular-cli-default-ts/model-signal/color-picker.component.ts b/code/frameworks/angular/template/stories_angular-cli-default-ts/model-signal/color-picker.component.ts index 41de6472fd06..0ad6f1552749 100644 --- a/code/frameworks/angular/template/stories_angular-cli-default-ts/model-signal/color-picker.component.ts +++ b/code/frameworks/angular/template/stories_angular-cli-default-ts/model-signal/color-picker.component.ts @@ -11,12 +11,5 @@ import { Component, model } from '@angular/core'; styleUrls: ['./color-picker.css'], }) export default class ColorPickerComponent { - /** - * The currently selected color. - * - * `model()` creates a two-way binding: an input `color` plus a compiler-synthesized - * `colorChange` output. Native `@storybook/angular` `model()` support means this no longer - * requires a hand-written `Args` interface or manual `argTypes` workaround. - */ color = model('#345F92'); } diff --git a/code/frameworks/angular/template/stories_angular-cli-default-ts/model-signal/color-picker.stories.ts b/code/frameworks/angular/template/stories_angular-cli-default-ts/model-signal/color-picker.stories.ts index 6b76697be797..2fd1a6958c49 100644 --- a/code/frameworks/angular/template/stories_angular-cli-default-ts/model-signal/color-picker.stories.ts +++ b/code/frameworks/angular/template/stories_angular-cli-default-ts/model-signal/color-picker.stories.ts @@ -7,15 +7,6 @@ import { expect, fn, userEvent, waitFor, within } from 'storybook/test'; import ColorPickerComponent from './color-picker.component'; -/** - * These stories exercise native `@storybook/angular` support for Angular's `model()` signal. - * - * `color = model('#345F92')` produces a two-way binding: an input `color` plus a - * compiler-synthesized `colorChange` output. Storybook now surfaces `color` as a Control and - * `colorChange` as an Action automatically — no hand-written `Args` interface / manual - * `argTypes` workaround is required (contrast with the `signal/` template stories, whose - * comments note Compodoc does not support signal inputs/outputs). - */ const meta: Meta = { component: ColorPickerComponent, tags: ['autodocs'], @@ -30,13 +21,6 @@ export default meta; type Story = StoryObj; -/** - * `colorChange` appears as an Action (fires on emit) and `color` is a Control/arg. - * - * `play`: - * 1. asserts the initial `color` arg rendered (it reached the component instance); - * 2. emits `colorChange` from inside the component and asserts the action spy received it. - */ export const ControlsAndActions: Story = { args: { color: '#345F92', @@ -53,17 +37,6 @@ export const ControlsAndActions: Story = { }, }; -/** - * Positive two-way `[(color)]` round-trip plus the live args-update path. - * - * `play` runs the sequence: - * 1. initial render → assert the initial `args.color` reached the component instance; - * 2. push a live arg change over the channel → assert the new value reaches the - * instance (the highest-risk path now that `color` is an Input); - * 3. trigger an in-component `colorChange` emission → assert it round-trips back to - * `args.color` (positive two-way `[(color)]`); - * 4. assert the action received `colorChange`. - */ export const TwoWayRoundTrip: Story = { // Live args updates inside `play` disrupt the Vitest runner; sandbox + Chromatic cover it. tags: ['!vitest'], diff --git a/code/frameworks/angular/template/stories_angular-cli-prerelease/model-signal/color-picker.component.ts b/code/frameworks/angular/template/stories_angular-cli-prerelease/model-signal/color-picker.component.ts index 41de6472fd06..0ad6f1552749 100644 --- a/code/frameworks/angular/template/stories_angular-cli-prerelease/model-signal/color-picker.component.ts +++ b/code/frameworks/angular/template/stories_angular-cli-prerelease/model-signal/color-picker.component.ts @@ -11,12 +11,5 @@ import { Component, model } from '@angular/core'; styleUrls: ['./color-picker.css'], }) export default class ColorPickerComponent { - /** - * The currently selected color. - * - * `model()` creates a two-way binding: an input `color` plus a compiler-synthesized - * `colorChange` output. Native `@storybook/angular` `model()` support means this no longer - * requires a hand-written `Args` interface or manual `argTypes` workaround. - */ color = model('#345F92'); } From a6d171537ac6fcde9e967f2880237f2ec7bf3bc0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 09:19:09 +0000 Subject: [PATCH 16/17] chore(angular): apply review feedback for model-signal stories/tests Agent-Logs-Url: https://github.com/storybookjs/storybook/sessions/ed8009ed-8fac-4deb-97ba-aad80ca78257 --- .../angular/src/client/docs/angular-properties.test.ts | 1 - .../model-signal/color-picker.stories.ts | 9 +++------ .../model-signal/color-picker.stories.ts | 9 +++------ 3 files changed, 6 insertions(+), 13 deletions(-) diff --git a/code/frameworks/angular/src/client/docs/angular-properties.test.ts b/code/frameworks/angular/src/client/docs/angular-properties.test.ts index 4e8164a59c85..361cb725ef80 100644 --- a/code/frameworks/angular/src/client/docs/angular-properties.test.ts +++ b/code/frameworks/angular/src/client/docs/angular-properties.test.ts @@ -15,7 +15,6 @@ const featureFlags = vi.hoisted(() => { // File hierarchy: __testfixtures__ / some-test-case / input.* const inputRegExp = /^input\..*$/; -// Mirrors the historic `SNAPSHOT_OS` global (removed with the legacy jest setup): // compodoc output is path-sensitive, so snapshots are OS-suffixed. const SNAPSHOT_OS = process.platform === 'win32' ? 'windows' : process.platform ? 'posix' : 'undefined'; diff --git a/code/frameworks/angular/template/stories_angular-cli-default-ts/model-signal/color-picker.stories.ts b/code/frameworks/angular/template/stories_angular-cli-default-ts/model-signal/color-picker.stories.ts index 2fd1a6958c49..8c2a8783d0fe 100644 --- a/code/frameworks/angular/template/stories_angular-cli-default-ts/model-signal/color-picker.stories.ts +++ b/code/frameworks/angular/template/stories_angular-cli-default-ts/model-signal/color-picker.stories.ts @@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/angular'; import { STORY_RENDERED, UPDATE_STORY_ARGS } from 'storybook/internal/core-events'; import { addons, useArgs } from 'storybook/preview-api'; -import { expect, fn, userEvent, waitFor, within } from 'storybook/test'; +import { expect, fn, userEvent, waitFor } from 'storybook/test'; import ColorPickerComponent from './color-picker.component'; @@ -25,9 +25,7 @@ export const ControlsAndActions: Story = { args: { color: '#345F92', }, - play: async ({ canvasElement, args }) => { - const canvas = within(canvasElement); - + play: async ({ canvas, args }) => { // `color` is a Control/arg: its value reached the component instance and rendered. await expect(canvas.getByTestId('current-color')).toHaveTextContent('#345F92'); @@ -56,8 +54,7 @@ export const TwoWayRoundTrip: Story = { }, }; }, - play: async ({ canvasElement, args, id }) => { - const canvas = within(canvasElement); + play: async ({ canvas, args, id }) => { const channel = addons.getChannel(); // 1. Initial render: the initial `args.color` reached the component instance. diff --git a/code/frameworks/angular/template/stories_angular-cli-prerelease/model-signal/color-picker.stories.ts b/code/frameworks/angular/template/stories_angular-cli-prerelease/model-signal/color-picker.stories.ts index 2fd1a6958c49..8c2a8783d0fe 100644 --- a/code/frameworks/angular/template/stories_angular-cli-prerelease/model-signal/color-picker.stories.ts +++ b/code/frameworks/angular/template/stories_angular-cli-prerelease/model-signal/color-picker.stories.ts @@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/angular'; import { STORY_RENDERED, UPDATE_STORY_ARGS } from 'storybook/internal/core-events'; import { addons, useArgs } from 'storybook/preview-api'; -import { expect, fn, userEvent, waitFor, within } from 'storybook/test'; +import { expect, fn, userEvent, waitFor } from 'storybook/test'; import ColorPickerComponent from './color-picker.component'; @@ -25,9 +25,7 @@ export const ControlsAndActions: Story = { args: { color: '#345F92', }, - play: async ({ canvasElement, args }) => { - const canvas = within(canvasElement); - + play: async ({ canvas, args }) => { // `color` is a Control/arg: its value reached the component instance and rendered. await expect(canvas.getByTestId('current-color')).toHaveTextContent('#345F92'); @@ -56,8 +54,7 @@ export const TwoWayRoundTrip: Story = { }, }; }, - play: async ({ canvasElement, args, id }) => { - const canvas = within(canvasElement); + play: async ({ canvas, args, id }) => { const channel = addons.getChannel(); // 1. Initial render: the initial `args.color` reached the component instance. From 9bf60590623d909876e67fa2483dca53e6d5a661 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 21 May 2026 11:46:57 +0200 Subject: [PATCH 17/17] chore(angular): fix formatting in NgComponentAnalyzer.test.ts Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/client/angular-beta/utils/NgComponentAnalyzer.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/frameworks/angular/src/client/angular-beta/utils/NgComponentAnalyzer.test.ts b/code/frameworks/angular/src/client/angular-beta/utils/NgComponentAnalyzer.test.ts index 149bd169be56..0d112970e9d4 100644 --- a/code/frameworks/angular/src/client/angular-beta/utils/NgComponentAnalyzer.test.ts +++ b/code/frameworks/angular/src/client/angular-beta/utils/NgComponentAnalyzer.test.ts @@ -47,7 +47,7 @@ describe('getComponentInputsOutputs', () => { }); }); -/* Commented out until we figure out how to handle the removal of ComponentFactoryResolver in Angular 22 + /* Commented out until we figure out how to handle the removal of ComponentFactoryResolver in Angular 22 See https://github.com/angular/angular/releases/tag/v22.0.0-next.7 it('should return I/O', () => {