fix(uix): SYS-A30-EFFECT — register inputId directly, not in a $effect (+ guard)

date/time/color-field assigned `field.inputId.current = opts.id.current` inside
a `$effect` — the A30 anti-pattern (an effect for a one-shot id registration is
wasteful + order-fragile). `opts.id` is available at construction and the
parent Field context resolves synchronously, so this is a plain constructor
assign. Mirrors the 12 field-family providers that already do it right (the fix
is byte-equivalent to number-field:583-587 / css-field). Verified by that
equivalence + the field providers' 15/15 tests (incl. color-field's
input/label wiring test); a live browser check was blocked by a port conflict
with another session's dev server.

Guard (A30, contracts.test.ts): flags `$effect(... inputId.current = opts.X.current ...)`.
- Proven to catch drift: reverting date-field to the effect form makes it fail.
- Scoped to an `opts.*` source on purpose: file-upload registers
  `this.hiddenInputId` (empty '' until a child part mounts) inside a $effect —
  that source is genuinely async, so the effect is JUSTIFIED, and the `opts.*`
  requirement leaves it green without an exception list. (The guard surfaced
  file-upload during development, which is how the distinction was found.)

check: 0 new type errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
active-uix
dev 3 months ago
parent ec100c4bb8
commit a2ae2c2007

@ -248,6 +248,30 @@ function collectA31Violations(): string[] {
.sort();
}
// A30: a child must register its `inputId` with the parent Field via a DIRECT
// constructor assign, never inside a `$effect` — an effect for a one-shot id
// registration is wasteful + order-fragile (the 12 field-family providers that
// do it right assign directly; date/time/color-field drifted into an effect).
//
// The source MUST be `opts.<x>.current` — a prop that is already available at
// construction, so the effect is pure overhead. A reactive source set LATER by
// a child part (file-upload's `this.hiddenInputId`, '' until the hidden-input
// child mounts) genuinely needs the effect to observe it; that is NOT the
// anti-pattern and the `opts.*` requirement correctly leaves it green.
function collectA30Violations(): string[] {
const root = join(HERE, 'soma', 'components');
// `$effect(() => { … inputId.current = opts.X.current … })`, tempered so the
// assign must live INSIDE this effect's body (stop at the `});` that closes it).
const re =
/\$effect\s*\(\s*\(\s*\)\s*=>\s*\{(?:(?!\}\);)[\s\S]){0,400}?inputId\.current\s*=\s*opts\.\w+\.current/;
return listSourceFiles(root)
.filter((file) => file.endsWith('.svelte.ts'))
.filter((file) => !ACTIVE_DEV_TRACK.has(basename(dirname(file))))
.filter((file) => re.test(stripComments(readFileSync(file, 'utf8'))))
.map((file) => `${file.replace(REPO_ROOT, '')}: inputId registered inside a $effect (A30)`)
.sort();
}
function collectComponentLangPathViolations(source: string, file: string): string[] {
const clean = stripComments(source);
const violations: string[] = [];
@ -561,6 +585,14 @@ describe('UIX layer contracts', () => {
).toEqual([]);
});
it('guards inputId Field registration against $effect wrapping (A30)', () => {
const violations = collectA30Violations();
expect(
violations,
`A30 — assign inputId directly in the constructor, not in a $effect:\n ${violations.join('\n ')}`
).toEqual([]);
});
it('guards Soma public component modules with local README docs', () => {
const missingReadmes = collectPublicSomaComponentDirs().filter(
(dir) => !existsSync(join(HERE, 'soma', 'components', dir, 'README.md'))

@ -518,11 +518,11 @@ export class ColorFieldInputProvider {
(v) => this.provider.setName(v)
);
$effect(() => {
const field = this.provider.field;
if (!field) return;
field.inputId.current = opts.id.current;
});
// Field integration: register our id so a parent Field can wire its Label /
// HelperText / ErrorText against the Input. Direct constructor assign (A30)
// — the parent Field context resolves at construction, same as number-field.
const field = this.provider.field;
if (field) field.inputId.current = opts.id.current;
}
readonly segments = $derived.by(() => this.provider.segmentContents);

@ -928,12 +928,11 @@ export class DateFieldInputProvider {
);
// Field integration: register our id so a parent Field can wire its
// Label / HelperText / ErrorText against the Input element.
$effect(() => {
const field = this.provider.field;
if (!field) return;
field.inputId.current = opts.id.current;
});
// Label / HelperText / ErrorText against the Input element. Direct
// constructor assign (A30) — the parent Field context resolves at
// construction, same as number-field / css-field; no $effect needed.
const field = this.provider.field;
if (field) field.inputId.current = opts.id.current;
}
readonly segments = $derived.by(() => this.provider.segmentContents);

@ -528,11 +528,11 @@ export class TimeFieldInputProvider {
(v) => this.provider.setName(v)
);
$effect(() => {
const field = this.provider.field;
if (!field) return;
field.inputId.current = opts.id.current;
});
// Field integration: register our id so a parent Field can wire its Label /
// HelperText / ErrorText against the Input. Direct constructor assign (A30)
// — the parent Field context resolves at construction, same as number-field.
const field = this.provider.field;
if (field) field.inputId.current = opts.id.current;
}
readonly segments = $derived.by(() => this.provider.segmentContents);

Loading…
Cancel
Save

Powered by TurnKey Linux.