Skip to content

Commit 2458c4c

Browse files
serpentbladeclaude
andcommitted
fix(target-angular): dedupe @ContentChild slot fields per distinct slot name
A template that references the same named slot in multiple locations (e.g. DataTable's `colHeader`, Slider's `bubble`) drove `emitScript` to emit one `@ContentChild` field, one ctx `interface`, and one `ngTemplateContextGuard` union member PER reference. The duplicate class members produced an esbuild "Duplicate member" parse error and duplicate interface identifiers (TS2300), which crashed the Angular build — and with it the Visual Regression Matrix dev server (webserver-start timeout) on every push. The 47-03 slot-decl dedup (Lit/Solid/Svelte/React) never covered Angular's inline `@ContentChild` emission path. Add the same distinct-slot-name dedup in `emitScript` (fields + ctx interfaces) and in `buildNgTemplateContextGuard` (union members), plus a regression test for a slot referenced twice. Regenerated the DataTable + Slider Angular leaves; verified end-to-end via the CLI that Slider emits a single `@ContentChild('bubble')`. Core + all six target suites + dist-parity green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ArTPHLhYMwrjvvoF9VXDe
1 parent 6bf8b32 commit 2458c4c

5 files changed

Lines changed: 73 additions & 92 deletions

File tree

packages/targets/angular/src/__tests__/emitScript.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,56 @@ describe('emitScript — TodoList slot context interfaces', () => {
176176
});
177177
});
178178

179+
describe('emitScript — repeated named-slot dedup (regression)', () => {
180+
// A template may reference the same named slot in multiple locations (e.g.
181+
// DataTable's `colHeader`, Slider's `bubble`). Each distinct name backs
182+
// exactly one @ContentChild field + one ctx interface; emitting one per
183+
// reference produced "Duplicate member" (esbuild) and TS2300 (duplicate
184+
// identifier), crashing the Angular build. Regression guard for that fix.
185+
function loadInlineIR(src: string): IRComponent {
186+
const result = parse(src, { filename: 'RepeatedSlot.rozie' });
187+
if (!result.ast) throw new Error('parse() returned null AST');
188+
const lowered = lowerToIR(result.ast, {
189+
modifierRegistry: createDefaultRegistry(),
190+
});
191+
if (!lowered.ir) throw new Error('lowerToIR() returned null IR');
192+
return lowered.ir;
193+
}
194+
195+
const SRC = `<rozie name="RepeatedSlot">
196+
<template>
197+
<div>
198+
<slot name="bubble" :value="1" />
199+
<slot name="bubble" :value="2" />
200+
</div>
201+
</template>
202+
</rozie>`;
203+
204+
it('emits exactly one @ContentChild for a slot referenced twice', () => {
205+
const ir = loadInlineIR(SRC);
206+
const { classBody } = emitScript(ir);
207+
const occurrences = classBody.match(/@ContentChild\('bubble'/g) ?? [];
208+
expect(occurrences).toHaveLength(1);
209+
});
210+
211+
it('emits exactly one ctx interface for a repeated slot', () => {
212+
const ir = loadInlineIR(SRC);
213+
const { interfaceDecls } = emitScript(ir);
214+
const joined = interfaceDecls.join('\n');
215+
const occurrences = joined.match(/interface BubbleCtx\b/g) ?? [];
216+
expect(occurrences).toHaveLength(1);
217+
});
218+
219+
it('ngTemplateContextGuard union has no repeated members', () => {
220+
const ir = loadInlineIR(SRC);
221+
const { classBody } = emitScript(ir);
222+
const occurrences = classBody.match(/\bBubbleCtx\b/g) ?? [];
223+
// BubbleCtx appears once in the @ContentChild field type and once in the
224+
// guard union — never twice in either site.
225+
expect(occurrences).toHaveLength(2);
226+
});
227+
});
228+
179229
describe('emitScript — per-block snapshot fixtures', () => {
180230
it('Counter.script.snap', async () => {
181231
const ir = loadIR('Counter');

packages/targets/angular/src/emit/emitScript.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -869,7 +869,17 @@ export function emitScript(
869869
interfaceDecls.push(genCode(typeDecl));
870870
}
871871
const slotFieldDecls: string[] = [];
872+
// Dedupe by DISTINCT slot name — a template may reference the same named
873+
// slot in multiple locations (e.g. DataTable's `colHeader`, Slider's
874+
// `bubble`), but each distinct name backs exactly one `@ContentChild` field
875+
// and one ctx interface. Without this, repeated references emit duplicate
876+
// class members ("Duplicate member" esbuild error) and duplicate interface
877+
// identifiers (TS2300), breaking the Angular build. Mirrors the per-target
878+
// `seenSlotNames` dedup in Lit/Solid/Svelte/React emitSlotDecl.ts (47-03).
879+
const seenSlotNames = new Set<string>();
872880
for (const slot of ir.slots) {
881+
if (seenSlotNames.has(slot.name)) continue;
882+
seenSlotNames.add(slot.name);
873883
const ctx = buildSlotCtx(slot);
874884
interfaceDecls.push(ctx.interfaceDecl);
875885
slotFieldDecls.push(ctx.fieldDecl);

packages/targets/angular/src/emit/refineSlotTypes.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,17 @@ export function buildNgTemplateContextGuard(
9999
slots: SlotDecl[],
100100
): string | null {
101101
if (slots.length === 0) return null;
102-
const ctxNames = slots.map((s) => slotCtxName(s.name));
102+
// Dedupe by distinct slot name — a slot referenced in multiple template
103+
// locations appears multiple times in `slots`, but each distinct name has a
104+
// single ctx type. Without this the union repeats members (`FooCtx | FooCtx`).
105+
const seenCtxNames = new Set<string>();
106+
const ctxNames: string[] = [];
107+
for (const s of slots) {
108+
const ctxName = slotCtxName(s.name);
109+
if (seenCtxNames.has(ctxName)) continue;
110+
seenCtxNames.add(ctxName);
111+
ctxNames.push(ctxName);
112+
}
103113
const unionType = ctxNames.join(' | ');
104114
return [
105115
`static ngTemplateContextGuard(`,

packages/ui/data-table/packages/angular/src/DataTable.ts

Lines changed: 1 addition & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -58,13 +58,6 @@ interface ColHeaderCtx {
5858
label: any;
5959
}
6060

61-
interface ColHeaderCtx {
62-
$implicit: { columnId: any; column: any; label: any };
63-
columnId: any;
64-
column: any;
65-
label: any;
66-
}
67-
6861
interface FilterCtx {
6962
$implicit: { columnId: any; uniqueValues: any; minMax: any };
7063
columnId: any;
@@ -97,67 +90,6 @@ interface CellCtx {
9790
value: any;
9891
}
9992

100-
interface SelectAllCtx {
101-
$implicit: { checked: any; indeterminate: any; toggle: any };
102-
checked: any;
103-
indeterminate: any;
104-
toggle: any;
105-
}
106-
107-
interface ColHeaderCtx {
108-
$implicit: { columnId: any; column: any; label: any };
109-
columnId: any;
110-
column: any;
111-
label: any;
112-
}
113-
114-
interface ColHeaderCtx {
115-
$implicit: { columnId: any; column: any; label: any };
116-
columnId: any;
117-
column: any;
118-
label: any;
119-
}
120-
121-
interface FilterCtx {
122-
$implicit: { columnId: any; uniqueValues: any; minMax: any };
123-
columnId: any;
124-
uniqueValues: any;
125-
minMax: any;
126-
}
127-
128-
interface SelectCellCtx {
129-
$implicit: { row: any; checked: any; toggle: any };
130-
row: any;
131-
checked: any;
132-
toggle: any;
133-
}
134-
135-
interface CellCtx {
136-
$implicit: { columnId: any; column: any; row: any; value: any };
137-
columnId: any;
138-
column: any;
139-
row: any;
140-
value: any;
141-
}
142-
143-
interface EditorCtx {
144-
$implicit: { columnId: any; column: any; row: any; value: any; commit: any; cancel: any };
145-
columnId: any;
146-
column: any;
147-
row: any;
148-
value: any;
149-
commit: any;
150-
cancel: any;
151-
}
152-
153-
interface CellCtx {
154-
$implicit: { columnId: any; column: any; row: any; value: any };
155-
columnId: any;
156-
column: any;
157-
row: any;
158-
value: any;
159-
}
160-
16193
interface DetailCtx {
16294
$implicit: { row: any };
16395
row: any;
@@ -894,19 +826,10 @@ export class DataTable {
894826
@ContentChild('groupBar', { read: TemplateRef }) groupBarTpl?: TemplateRef<GroupBarCtx>;
895827
@ContentChild('selectAll', { read: TemplateRef }) selectAllTpl?: TemplateRef<SelectAllCtx>;
896828
@ContentChild('colHeader', { read: TemplateRef }) colHeaderTpl?: TemplateRef<ColHeaderCtx>;
897-
@ContentChild('colHeader', { read: TemplateRef }) colHeaderTpl?: TemplateRef<ColHeaderCtx>;
898829
@ContentChild('filter', { read: TemplateRef }) filterTpl?: TemplateRef<FilterCtx>;
899830
@ContentChild('selectCell', { read: TemplateRef }) selectCellTpl?: TemplateRef<SelectCellCtx>;
900831
@ContentChild('editor', { read: TemplateRef }) editorTpl?: TemplateRef<EditorCtx>;
901832
@ContentChild('cell', { read: TemplateRef }) cellTpl?: TemplateRef<CellCtx>;
902-
@ContentChild('selectAll', { read: TemplateRef }) selectAllTpl?: TemplateRef<SelectAllCtx>;
903-
@ContentChild('colHeader', { read: TemplateRef }) colHeaderTpl?: TemplateRef<ColHeaderCtx>;
904-
@ContentChild('colHeader', { read: TemplateRef }) colHeaderTpl?: TemplateRef<ColHeaderCtx>;
905-
@ContentChild('filter', { read: TemplateRef }) filterTpl?: TemplateRef<FilterCtx>;
906-
@ContentChild('selectCell', { read: TemplateRef }) selectCellTpl?: TemplateRef<SelectCellCtx>;
907-
@ContentChild('cell', { read: TemplateRef }) cellTpl?: TemplateRef<CellCtx>;
908-
@ContentChild('editor', { read: TemplateRef }) editorTpl?: TemplateRef<EditorCtx>;
909-
@ContentChild('cell', { read: TemplateRef }) cellTpl?: TemplateRef<CellCtx>;
910833
@ContentChild('detail', { read: TemplateRef }) detailTpl?: TemplateRef<DetailCtx>;
911834
templates = input<Record<string, TemplateRef<unknown>> | undefined>(undefined);
912835
private __rozieWatchInitial_0 = true;
@@ -3363,7 +3286,7 @@ export class DataTable {
33633286
static ngTemplateContextGuard(
33643287
_dir: DataTable,
33653288
_ctx: unknown,
3366-
): _ctx is DefaultCtx | GroupBarCtx | SelectAllCtx | ColHeaderCtx | ColHeaderCtx | FilterCtx | SelectCellCtx | EditorCtx | CellCtx | SelectAllCtx | ColHeaderCtx | ColHeaderCtx | FilterCtx | SelectCellCtx | CellCtx | EditorCtx | CellCtx | DetailCtx {
3289+
): _ctx is DefaultCtx | GroupBarCtx | SelectAllCtx | ColHeaderCtx | FilterCtx | SelectCellCtx | EditorCtx | CellCtx | DetailCtx {
33673290
return true;
33683291
}
33693292

packages/ui/slider/packages/angular/src/Slider.ts

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,6 @@ interface BubbleCtx {
1414
value: any;
1515
}
1616

17-
interface BubbleCtx {
18-
$implicit: { value: any };
19-
value: any;
20-
}
21-
22-
interface BubbleCtx {
23-
$implicit: { value: any };
24-
value: any;
25-
}
26-
2717
function __rozieDisplay(v: unknown): string {
2818
if (v == null) return '';
2919
if (typeof v === 'string') return v;
@@ -293,8 +283,6 @@ export class Slider {
293283
change = output<unknown>();
294284
@ContentChild('mark', { read: TemplateRef }) markTpl?: TemplateRef<MarkCtx>;
295285
@ContentChild('bubble', { read: TemplateRef }) bubbleTpl?: TemplateRef<BubbleCtx>;
296-
@ContentChild('bubble', { read: TemplateRef }) bubbleTpl?: TemplateRef<BubbleCtx>;
297-
@ContentChild('bubble', { read: TemplateRef }) bubbleTpl?: TemplateRef<BubbleCtx>;
298286
templates = input<Record<string, TemplateRef<unknown>> | undefined>(undefined);
299287

300288
fillStyle = computed(() => {
@@ -461,7 +449,7 @@ export class Slider {
461449
static ngTemplateContextGuard(
462450
_dir: Slider,
463451
_ctx: unknown,
464-
): _ctx is MarkCtx | BubbleCtx | BubbleCtx | BubbleCtx {
452+
): _ctx is MarkCtx | BubbleCtx {
465453
return true;
466454
}
467455

0 commit comments

Comments
 (0)