From 62bcb1268f950b0687f878f3b2da9abed6cd3203 Mon Sep 17 00:00:00 2001 From: dev Date: Sat, 25 Apr 2026 17:01:56 +0200 Subject: [PATCH] soma/morfo: fix conditional attr cleanup in resolvePartAttrs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real bug surfaced by user testing the Toggle pilot: setting `invalid` to true emitted `data-invalid` and `aria-invalid` correctly, but flipping it back to false left the attrs on the DOM. The red border from CSS based on `[data-invalid]` stayed lit. Cause: `resolvePartAttrs` skipped declared attrs whose condition was falsy. The map handed to `dom.apply` lacked the key, so dom.apply (which only iterates the keys it receives) had no instruction to remove the attr from the previous tick. The function's JSDoc claimed it emitted `undefined` for non-matching attrs. The implementation didn't match. Now it does: - Conditional attrs with falsy condition are emitted as `undefined` → `dom.apply` removes them from the DOM and Svelte ignores them at render. - Data entries without a `value` source are still skipped entirely (they are documentation in the morfo, not runtime-managed attrs). Same fix covers all conditional attrs: data-disabled, data-readonly, data-required, data-invalid, aria-disabled, aria-readonly, aria-required, aria-invalid, aria-label — all toggled on/off cleanly now. Regression test added covering the invalid true→false→true cycle. Verification: 5/5 runtime tests, 76/76 focused suite, 66/66 morfo-check, 68/68 smoke. --- src/uix/soma/morfo/resolver.ts | 30 ++++++++++++------- src/uix/soma/morfo/runtime.svelte.test.ts | 36 +++++++++++++++++++++++ 2 files changed, 55 insertions(+), 11 deletions(-) diff --git a/src/uix/soma/morfo/resolver.ts b/src/uix/soma/morfo/resolver.ts index a99517460..6084d1e51 100644 --- a/src/uix/soma/morfo/resolver.ts +++ b/src/uix/soma/morfo/resolver.ts @@ -107,9 +107,17 @@ export function resolveMorfoAriaValue(entry: MorfoAriaEntry, bindings: MorfoBind /** * Resolves a part's full attribute map (role + data-* + aria-*) against the - * given bindings. Returns `undefined`-valued entries for declared attrs that - * shouldn't be emitted in the current state — the caller decides whether to - * skip them (Svelte does it automatically; `dom.apply` removes them). + * given bindings. + * + * The map ALWAYS includes every declared attr (data with a `value` source + + * every aria entry). Attrs whose condition is falsy or whose value source + * resolves to nothing are emitted as `undefined`, which `dom.apply` reads as + * "remove this attribute" and Svelte ignores during render. This is what + * lets the runtime clean up attrs that previously matched their condition + * but no longer do (e.g. `data-invalid` toggling from set to absent). + * + * Entries with no `value` source on `data` are skipped entirely — they are + * documentation in the morfo, not runtime-managed attrs. */ export function resolvePartAttrs( partMeta: MorfoPart, @@ -123,19 +131,19 @@ export function resolvePartAttrs( for (const data of partMeta.data) { if (!data.value) continue; - if (!shouldEmitMorfoEntry(data.condition, bindings)) continue; - const value = resolveMorfoDataValue(data, bindings); - if (value !== undefined) { - result[data.attr] = value; + if (!shouldEmitMorfoEntry(data.condition, bindings)) { + result[data.attr] = undefined; + continue; } + result[data.attr] = resolveMorfoDataValue(data, bindings); } for (const aria of partMeta.aria) { - if (!shouldEmitMorfoEntry(aria.condition, bindings)) continue; - const value = resolveMorfoAriaValue(aria, bindings); - if (value !== undefined) { - result[aria.attr] = value; + if (!shouldEmitMorfoEntry(aria.condition, bindings)) { + result[aria.attr] = undefined; + continue; } + result[aria.attr] = resolveMorfoAriaValue(aria, bindings); } return result; diff --git a/src/uix/soma/morfo/runtime.svelte.test.ts b/src/uix/soma/morfo/runtime.svelte.test.ts index ef6595a80..0e21258d7 100644 --- a/src/uix/soma/morfo/runtime.svelte.test.ts +++ b/src/uix/soma/morfo/runtime.svelte.test.ts @@ -125,6 +125,42 @@ describe('createMorfoRuntime', () => { cleanup() }) + it('removes conditional attrs when their condition flips back to false', () => { + const ref = state(null) + const invalid = state(false) + const { cleanup } = withEffectRoot(() => { + const id = state('tog-1') + const r = createMorfoRuntime(toggleMorfo, { + dom, + states: { on: () => false }, + props: { + disabled: () => false, + readonly: () => false, + required: () => false, + invalid: () => invalid.current, + ariaLabel: () => undefined + } + }) + r.registerPart('provider', { id, ref }) + }) + + ref.current = target + flushSync() + expect(target.hasAttribute('data-invalid')).toBe(false) + expect(target.hasAttribute('aria-invalid')).toBe(false) + + invalid.current = true + flushSync() + expect(target.getAttribute('data-invalid')).toBe('') + expect(target.getAttribute('aria-invalid')).toBe('true') + + invalid.current = false + flushSync() + expect(target.hasAttribute('data-invalid')).toBe(false) + expect(target.hasAttribute('aria-invalid')).toBe(false) + cleanup() + }) + it('throws when registering an unknown part', () => { const { cleanup } = withEffectRoot(() => { const r = createMorfoRuntime(toggleMorfo, { dom })