soma/morfo: fix conditional attr cleanup in resolvePartAttrs

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.
morfo-runtime
dev 6 months ago
parent 8ede535305
commit 62bcb1268f

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

@ -125,6 +125,42 @@ describe('createMorfoRuntime', () => {
cleanup()
})
it('removes conditional attrs when their condition flips back to false', () => {
const ref = state<HTMLElement | null>(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 })

Loading…
Cancel
Save

Powered by TurnKey Linux.