Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
fd4a51d
feat(angular): detect model() signal output in type inference (Layer A)
valentinpalkovic May 18, 2026
21c245e
feat(angular): detect model() signal binding at runtime (Layer C)
valentinpalkovic May 19, 2026
0588d2f
feat(angular): detect model() signal output in compodoc autodocs (Lay…
valentinpalkovic May 19, 2026
792098b
test(angular): model() signal stories + changelog (cross-cutting)
valentinpalkovic May 19, 2026
332fe51
refactor(angular): address review feedback for model() detection (doc…
valentinpalkovic May 19, 2026
a74018a
refactor(angular): remove component instantiation from analyzer; stat…
valentinpalkovic May 19, 2026
2f86d33
docs(angular): trim redundant comments in model() detection
valentinpalkovic May 19, 2026
19175ef
docs(angular): trim transcript prose, drop changelog entry
valentinpalkovic May 19, 2026
ee48639
fix(angular): don't call useArgs() inside model-signal play
valentinpalkovic May 19, 2026
436e31c
Apply suggestion from @valentinpalkovic
valentinpalkovic May 19, 2026
5b66f0f
Apply suggestion from @valentinpalkovic
valentinpalkovic May 19, 2026
27538e4
Merge branch 'next' into valentin/angular-model-signal-outputs
valentinpalkovic May 19, 2026
1fc92e8
Apply suggestion from @valentinpalkovic
valentinpalkovic May 20, 2026
a6d29d0
Apply suggestion from @valentinpalkovic
valentinpalkovic May 20, 2026
23634ba
Apply suggestions from code review
valentinpalkovic May 20, 2026
2d047d3
Apply suggestions from code review
valentinpalkovic May 20, 2026
8eab551
Merge branch 'next' into valentin/angular-model-signal-outputs
valentinpalkovic May 20, 2026
a6d1715
chore(angular): apply review feedback for model-signal stories/tests
Copilot May 21, 2026
9d4eccc
Merge branch 'next' into valentin/angular-model-signal-outputs
valentinpalkovic May 21, 2026
9bf6059
chore(angular): fix formatting in NgComponentAnalyzer.test.ts
valentinpalkovic May 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,93 @@ describe('getComponentInputsOutputs', () => {
*/
});

describe('getComponentInputsOutputs (signal-based I/O)', () => {
// 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<string, unknown>, outputs: Record<string, string>) => {
class FooComponent {}
(FooComponent as any).ɵcmp = { inputs, outputs };
return FooComponent;
};

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<Event>();

@Output('outputPropertyName') public outputWithBindingPropertyName =
new EventEmitter<Event>();
}

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 EventEmitter @Output (decorator path, unchanged)', () => {
@Component({ template: '', standalone: true })
class FooComponent {
@Output() public emitter = new EventEmitter<string>();
}

const { outputs } = getComponentInputsOutputs(FooComponent);

expect(outputs).toContainEqual({ propName: 'emitter', templateName: 'emitter' });
});

it('detects input() / output() signal members from ɵcmp', () => {
const FooComponent = withCmp(
{ signalInput: ['signalInput', 1] },
{ signalOutput: 'signalOutput' }
);

const { inputs, outputs } = getComponentInputsOutputs(FooComponent);

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);

expect(inputs).toContainEqual({ propName: 'color', templateName: 'color' });
expect(outputs).toContainEqual({ propName: 'color', templateName: 'colorChange' });

expect(inputs).toContainEqual({ propName: 'reqd', templateName: 'reqd' });
expect(outputs).toContainEqual({ propName: 'reqd', templateName: 'reqdChange' });

// Aliased model(): the resolved binding name (`al`/`alChange`) flows through.
expect(inputs).toContainEqual({ propName: 'aliased', templateName: 'al' });
expect(outputs).toContainEqual({ propName: 'aliased', templateName: 'alChange' });
});
});

describe('isDeclarable', () => {
it('should return true with a Component', () => {
@Component({})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
Output,
Pipe,
ɵReflectionCapabilities as ReflectionCapabilities,
ɵgetComponentDef as getComponentDef,
} from '@angular/core';

const reflectionCapabilities = new ReflectionCapabilities();
Expand Down Expand Up @@ -46,44 +47,102 @@ 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);

// Add signal-based I/O, which the decorator path above cannot see.
return addSignalInputsOutputs(component, decoratorDerived);
};

const hasEntry = (
list: { propName: string; templateName: string }[],
propName: string,
templateName: string
) => list.some((e) => e.propName === propName || e.templateName === templateName);

/**
* `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,
base: ComponentInputsOutputs
): ComponentInputsOutputs => {
const result: ComponentInputsOutputs = {
inputs: [...base.inputs],
outputs: [...base.outputs],
};

let def: any;
try {
def = getComponentDef(component);
} catch {
// `ɵgetComponentDef` may be unavailable for non-component classes.
return result;
}
if (!def) {
return result;
}

// `ɵcmp` keys the I/O maps by template (binding) name, not property name:
// def.inputs: { [templateName]: propName | [propName, flags, transform] }
// def.outputs: { [templateName]: propName }
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 });
}
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],
};
}
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 });
}
return previousValue;
}, initialValue);
}

return result;
};

export const isDeclarable = (component: any): boolean => {
Expand Down
47 changes: 47 additions & 0 deletions code/frameworks/angular/src/client/compodoc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,10 +239,30 @@ export const extractArgTypesFromData = (componentData: Class | Directive | Injec
| 'inputsClass'
| 'outputsClass';

// 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.
const inputClassNames = new Set<string>(
(((componentData as any).inputsClass as Property[]) || []).map((item) => item.name)
);
Comment thread
valentinpalkovic marked this conversation as resolved.
const modelProperties: Property[] = (
((componentData as any).outputsClass as Property[]) || []
).filter((item) => inputClassNames.has(item.name));
const modelPropertyNames = new Set<string>(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 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;
}

const defaultValue = isMethod(item) ? undefined : extractDefaultValue(item as Property);

const type: SBType =
Expand Down Expand Up @@ -273,6 +293,33 @@ export const extractArgTypesFromData = (componentData: Class | Directive | Injec
});
});

// 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`;

// 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,
type: { name: 'other', value: 'void' } as SBType,
action: changeName,
table: {
category: 'outputs',
type: {
summary: `(e: ${item.type}) => void`,
required: !item.optional,
},
},
};

if (!sectionToItems.outputs) {
sectionToItems.outputs = [];
}
sectionToItems.outputs.push(argType);
});

const SECTIONS = [
'properties',
'inputs',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
{
"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",
"type": {
"required": true,
"summary": "(e: string) => void",
},
},
"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",
"type": {
"required": true,
"summary": "(e: boolean) => void",
},
},
"type": {
"name": "other",
"value": "void",
},
},
}
Loading
Loading