Skip to content

Commit 0fef1d1

Browse files
committed
feat(26-05): synthesize inlined __rozieDisplay fn + delegating class method (gated)
- emitAngular appends a module-scope `function __rozieDisplay(v)` to the module-decls bucket AND a delegating class method `rozieDisplay(v) { return __rozieDisplay(v); }` to the class body, BOTH gated on tmplResult.hasDisplayWrap - byte-equivalent algorithm to the runtime-package helper (null->'' / string passthrough / object->JSON.stringify(v,null,2) / else String(v)) - NO @rozie/runtime-angular import (package does not exist; convention forbids; spreadBinding test guard still green) - non-wrapping components stay byte-identical to pre-phase (SPEC-3) — only the two wrapping fixtures (Modal/TodoList) carry the synthesis - rebless target-angular suite for the wrapping cases (TodoList/Modal/TreeNode/ model-sigil); update Uppy :accept double-read test to the wrapped form, matching React's landed accept={rozieDisplay(...)} cross-target behavior - dist-parity rebless deferred to Plan 07 (cold-gate); cross-target match-* snapshots unaffected (curated fixtures do not exercise the wrap path)
1 parent cd8a173 commit 0fef1d1

8 files changed

Lines changed: 111 additions & 11 deletions

File tree

packages/targets/angular/fixtures/Modal.template.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11

22
@if (open()) {
33
<div class="modal-backdrop" #backdropEl (click)="_guardedHandler0($event)">
4-
<div #dialogEl class="modal-dialog" role="dialog" aria-modal="true" [attr.aria-label]="title() || undefined" tabindex="-1">
4+
<div #dialogEl class="modal-dialog" role="dialog" aria-modal="true" [attr.aria-label]="rozieDisplay(title() || undefined)" tabindex="-1">
55
@if (title() || (headerTpl ?? templates()?.['header'])) {
66
<header>
77
@if ((headerTpl ?? templates()?.['header'])) {

packages/targets/angular/fixtures/Modal.ts.snap

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ interface FooterCtx {
1717
close: any;
1818
}
1919

20+
function __rozieDisplay(v: unknown): string {
21+
if (v == null) return '';
22+
if (typeof v === 'string') return v;
23+
if (typeof v === 'object') return JSON.stringify(v, null, 2);
24+
return String(v);
25+
}
26+
2027
@Component({
2128
selector: 'rozie-modal',
2229
standalone: true,
@@ -25,7 +32,7 @@ interface FooterCtx {
2532
2633
@if (open()) {
2734
<div class="modal-backdrop" #backdropEl (click)="_guardedHandler0($event)">
28-
<div #dialogEl class="modal-dialog" role="dialog" aria-modal="true" [attr.aria-label]="title() || undefined" tabindex="-1">
35+
<div #dialogEl class="modal-dialog" role="dialog" aria-modal="true" [attr.aria-label]="rozieDisplay(title() || undefined)" tabindex="-1">
2936
@if (title() || (headerTpl ?? templates()?.['header'])) {
3037
<header>
3138
@if ((headerTpl ?? templates()?.['header'])) {
@@ -174,6 +181,8 @@ export class Modal {
174181
if ($event.target !== $event.currentTarget) return;
175182
this.closeOnBackdrop() && this._close();
176183
};
184+
185+
rozieDisplay(v: unknown): string { return __rozieDisplay(v); }
177186
}
178187
179188
export default Modal;

packages/targets/angular/fixtures/TodoList.template.snap

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
} @else {
77
88
9-
<h3>{{ title() }} ({{ remaining() }} remaining)</h3>
9+
<h3>{{ title() }} ({{ rozieDisplay(remaining()) }} remaining)</h3>
1010
1111
}
1212
</header>
@@ -25,7 +25,7 @@
2525
<ng-container *ngTemplateOutlet="(defaultTpl ?? templates()?.['defaultSlot']); context: _defaultSlot_ctx_3(item)" />
2626
} @else {
2727

28-
<label><input type="checkbox" [checked]="item.done" (change)="toggle(item.id)" /><span>{{ item.text }}</span></label>
28+
<label><input type="checkbox" [checked]="item.done" (change)="toggle(item.id)" /><span>{{ rozieDisplay(item.text) }}</span></label>
2929
<button aria-label="Remove" (click)="removeItem(item.id)">×</button>
3030

3131
}

packages/targets/angular/fixtures/TodoList.ts.snap

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ interface DefaultCtx {
1717

1818
interface EmptyCtx {}
1919

20+
function __rozieDisplay(v: unknown): string {
21+
if (v == null) return '';
22+
if (typeof v === 'string') return v;
23+
if (typeof v === 'object') return JSON.stringify(v, null, 2);
24+
return String(v);
25+
}
26+
2027
@Component({
2128
selector: 'rozie-todo-list',
2229
standalone: true,
@@ -30,7 +37,7 @@ interface EmptyCtx {}
3037
} @else {
3138
3239
33-
<h3>{{ title() }} ({{ remaining() }} remaining)</h3>
40+
<h3>{{ title() }} ({{ rozieDisplay(remaining()) }} remaining)</h3>
3441
3542
}
3643
</header>
@@ -49,7 +56,7 @@ interface EmptyCtx {}
4956
<ng-container *ngTemplateOutlet="(defaultTpl ?? templates()?.['defaultSlot']); context: _defaultSlot_ctx_3(item)" />
5057
} @else {
5158
52-
<label><input type="checkbox" [checked]="item.done" (change)="_toggle(item.id)" /><span>{{ item.text }}</span></label>
59+
<label><input type="checkbox" [checked]="item.done" (change)="_toggle(item.id)" /><span>{{ rozieDisplay(item.text) }}</span></label>
5360
<button aria-label="Remove" (click)="removeItem(item.id)">×</button>
5461
5562
}
@@ -285,6 +292,8 @@ export class TodoList {
285292
};
286293
287294
private _defaultSlot_ctx_3 = (item: any) => ({ $implicit: { item: item, toggle: () => this._toggle(item.id), remove: () => this.removeItem(item.id) }, item: item, toggle: () => this._toggle(item.id), remove: () => this.removeItem(item.id) });
295+
296+
rozieDisplay(v: unknown): string { return __rozieDisplay(v); }
288297
}
289298
290299
export default TodoList;

packages/targets/angular/fixtures/composition/TreeNode.angular.ts.snap

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
11
import { Component, DestroyRef, ElementRef, Renderer2, ViewEncapsulation, afterRenderEffect, effect, forwardRef, inject, viewChild } from '@angular/core';
22

3+
function __rozieDisplay(v: unknown): string {
4+
if (v == null) return '';
5+
if (typeof v === 'string') return v;
6+
if (typeof v === 'object') return JSON.stringify(v, null, 2);
7+
return String(v);
8+
}
9+
310
@Component({
411
selector: 'rozie-tree-node',
512
standalone: true,
613
imports: [forwardRef(() => TreeNode)],
714
template: `
815
916
<li #rozieSpread_0 #rozieListenersTarget_1>
10-
<span>{{ $props.label }}</span>
17+
<span>{{ rozieDisplay($props.label) }}</span>
1118
<rozie-tree-node [node]="$props.children"></rozie-tree-node>
1219
</li>
1320
@@ -139,6 +146,8 @@ export class TreeNode {
139146
});
140147
}
141148
});
149+
150+
rozieDisplay(v: unknown): string { return __rozieDisplay(v); }
142151
}
143152
144153
export default TreeNode;

packages/targets/angular/src/__tests__/template-double-read-accessor.test.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,8 +93,13 @@ describe('Angular template double-read accessor — Uppy :accept', () => {
9393
const { code } = emitAngular(ir, { filename, source: src });
9494
const template = templateOf(code);
9595

96-
// Attribute now binds to the getter, not an inline ternary.
97-
expect(template).toContain('[accept]="__accept"');
96+
// Attribute now binds to the getter, not an inline ternary. Phase 26
97+
// (SPEC-4): `accept` is an Array-typed (`any` element) ternary value, not
98+
// provably primitive, so the binding additionally wraps the getter read in
99+
// the synthesized `rozieDisplay` class method — matching React's landed
100+
// `accept={rozieDisplay(...)}` cross-target behavior. The double-read
101+
// getter hoist (`__accept`) is unaffected; the wrap composes over it.
102+
expect(template).toContain('[accept]="rozieDisplay(__accept)"');
98103

99104
// The getter exists, reads the signal exactly once into a local, and
100105
// performs the guard-and-use against the narrowed local.

packages/targets/angular/src/emitAngular.ts

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,49 @@ export interface EmitAngularResult {
158158
diagnostics: Diagnostic[];
159159
}
160160

161+
/**
162+
* Phase 26 (SPEC-1/SPEC-2/SPEC-4, D-01/D-02 via RESEARCH Pitfall 4) — the
163+
* INLINED, module-scope `rozieDisplay` helper for the Angular target.
164+
*
165+
* There is NO `@rozie/runtime-angular` package and project convention forbids
166+
* one (`emitAngular.ts` header + `collectAngularImports`; `spreadBinding.test.ts`
167+
* asserts `not.toContain('@rozie/runtime-angular')`). So unlike the other four
168+
* non-Vue targets — which import `rozieDisplay` from their `@rozie/runtime-*`
169+
* package — Angular emits the helper body verbatim at module scope.
170+
*
171+
* The body is algorithmically byte-equivalent to the runtime-package helper
172+
* (`packages/runtime/{react,solid,svelte,lit}/src/rozieDisplay.ts`): null/
173+
* undefined → '', string passthrough, object (incl. arrays) → 2-space JSON,
174+
* else `String(v)`.
175+
*
176+
* Angular templates cannot call a module-scope free function (AOT resolves
177+
* interpolation/binding identifiers against the COMPONENT instance), so the
178+
* template never calls `__rozieDisplay` directly — it calls the delegating
179+
* CLASS METHOD synthesized in `DISPLAY_CLASS_METHOD`, which forwards to this fn.
180+
*
181+
* Both are emitted ONLY when at least one interpolation actually wrapped
182+
* (`tmplResult.hasDisplayWrap`), keeping non-wrapping components byte-identical
183+
* to pre-phase (SPEC-3).
184+
*/
185+
const INLINE_DISPLAY_FN = [
186+
'function __rozieDisplay(v: unknown): string {',
187+
" if (v == null) return '';",
188+
" if (typeof v === 'string') return v;",
189+
" if (typeof v === 'object') return JSON.stringify(v, null, 2);",
190+
' return String(v);',
191+
'}',
192+
].join('\n');
193+
194+
/**
195+
* Phase 26 (D-02) — the delegating class method synthesized into the component
196+
* body (via the `allFieldInjections` / classBodyParts mechanism, the same path
197+
* the `$expose` methods + listener field initializers use). The template calls
198+
* `rozieDisplay(expr)` against `this`; this method forwards to the inlined
199+
* module-scope `__rozieDisplay`. Gated on `tmplResult.hasDisplayWrap`.
200+
*/
201+
const DISPLAY_CLASS_METHOD =
202+
'rozieDisplay(v: unknown): string { return __rozieDisplay(v); }';
203+
161204
export function emitAngular(
162205
ir: IRComponent,
163206
opts: EmitAngularOptions = {},
@@ -435,6 +478,12 @@ export function emitAngular(
435478
// as component members so Angular's `strictTemplates` resolves them
436479
// against the class instead of failing TS2339.
437480
...tmplResult.usedGlobals.map((g) => `protected readonly ${g} = ${g};`),
481+
// Phase 26 (D-02) — the delegating `rozieDisplay` class method, synthesized
482+
// ONLY when an interpolation wrapped. The template calls it against `this`;
483+
// it forwards to the inlined module-scope `__rozieDisplay` (emitted below
484+
// via the interfaceDecls bucket). Both gated on the same flag so a
485+
// non-wrapping component stays byte-identical to pre-phase (SPEC-3).
486+
...(tmplResult.hasDisplayWrap ? [DISPLAY_CLASS_METHOD] : []),
438487
];
439488

440489
// Find the constructor block and splice the listener effects + renderer
@@ -508,9 +557,19 @@ export function emitAngular(
508557
? componentImportsLines.join('\n') + '\n'
509558
: '';
510559

560+
// Phase 26 (D-01-correction/D-02) — when any interpolation wrapped, append the
561+
// inlined module-scope `function __rozieDisplay(v)` to the module-scope decls
562+
// bucket (rendered above the @Component class by the shell). NO
563+
// `@rozie/runtime-angular` import is emitted (the package does not exist;
564+
// convention forbids it). When nothing wrapped, the bucket is unchanged so the
565+
// emitted file is byte-identical to pre-phase (SPEC-3).
566+
const moduleDecls = tmplResult.hasDisplayWrap
567+
? [...scriptResult.interfaceDecls, INLINE_DISPLAY_FN]
568+
: scriptResult.interfaceDecls;
569+
511570
const { ms, scriptOutputOffset, scriptMap: shellScriptMap, userCodeLineOffset } = buildShell({
512571
importLines: imports.render(),
513-
interfaceDecls: scriptResult.interfaceDecls,
572+
interfaceDecls: moduleDecls,
514573
decorator,
515574
componentName: ir.name,
516575
classBody,

packages/targets/angular/tests/__snapshots__/model-sigil.test.ts.snap

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,19 @@ exports[`Angular $model producer-side two-way-write sigil > emitted model fixtur
44
"import { Component, DestroyRef, ElementRef, Renderer2, ViewEncapsulation, afterRenderEffect, effect, forwardRef, inject, model, signal, viewChild } from '@angular/core';
55
import { NG_VALUE_ACCESSOR } from '@angular/forms';
66
7+
function __rozieDisplay(v: unknown): string {
8+
if (v == null) return '';
9+
if (typeof v === 'string') return v;
10+
if (typeof v === 'object') return JSON.stringify(v, null, 2);
11+
return String(v);
12+
}
13+
714
@Component({
815
selector: 'rozie-model-sigil',
916
standalone: true,
1017
template: \`
1118
12-
<button [title]="__title" #rozieSpread_0 (click)="value.set(value() + 1), __rozieCvaOnChange(value() + 1)" #rozieListenersTarget_1>{{ value() }}</button>
19+
<button [title]="rozieDisplay(__title)" #rozieSpread_0 (click)="value.set(value() + 1), __rozieCvaOnChange(value() + 1)" #rozieListenersTarget_1>{{ value() }}</button>
1320
1421
\`,
1522
providers: [
@@ -201,6 +208,8 @@ export class ModelSigil {
201208
});
202209
}
203210
});
211+
212+
rozieDisplay(v: unknown): string { return __rozieDisplay(v); }
204213
}
205214
206215
export default ModelSigil;

0 commit comments

Comments
 (0)