feat(soma): P1 — part-present lee el REGISTRO VIVO; el callback a mano pasa a override

El runtime ya poseía la verdad (registrations + liveReg) pero bindings.parts
solo se llenaba si el provider recordaba cablear `parts: { x: () => id }`;
olvidarlo hacía evaluar part-present a false EN SILENCIO (la enfermedad de
deriva que este eje persigue). Ahora:

- readBindings llena bindings.parts desde el registro para cada parte que
  los planes de la parte referencian (deps.parts del compilador), ANTES de
  leer sources.parts/reg.parts — el feed explícito sigue ganando por orden
  de escritura.
- Regla de ambigüedad: con MÁS de una registración (hermanos repetidos) el
  registro CALLA — solo el provider sabe a qué hermano apunta un attr; el
  callback explícito sigue siendo la única voz ahí.
- La membresía vive en arrays planos (push/splice del attachment), que
  Svelte no observa: `partsRevision` ($state) se bumpea en cada mutación y
  el resolutor lo toca, así las bolsas dependientes se re-derivan. Grueso a
  propósito: el churn de partes es raro.
- createBindingsScratch crea el bucket también cuando deps.parts existe
  (antes quedaba undefined sin feed a mano y el default no tenía dónde).

Tests (4): registro responde sin callback · el explícito gana · hermanos
silencian el registro · membresía reactiva vía protocolo de attachment
(attachRef es un attachment de Svelte — el test ejecuta attach/cleanup a
mano, las mismas llamadas del template). Suite soma 1304/1304.

Barrido de redundancia (callbacks a mano ahora superfluos en partes de
instancia única) = seguimiento, no este commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
alpha-0.1-background
dev 1 month ago
parent b549cfee59
commit e5cc05a029

@ -173,6 +173,124 @@ describe('createSomaRuntime', () => {
cleanup();
});
describe('live part registry as the part-refs default (P1)', () => {
// The morfo declares BOTH parts, so registering `content` is enough for
// the trigger's partRef attr — no hand-fed `parts:` callback. Before
// this default, a provider that forgot the callback shipped a
// `part-present` that evaluated false in silence.
const registryMorfo = {
name: 'RegistryProbe',
kebab: 'registry-probe',
scope: ['soma'],
parts: [
{
name: 'Trigger',
kebab: 'trigger',
kind: 'public',
defaultElement: 'button',
optional: false,
data: [],
aria: [
{
attr: 'aria-controls',
value: { kind: 'partRef', target: 'content' },
condition: { when: 'part-present', part: 'content' }
}
]
},
{
name: 'Content',
kebab: 'content',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [],
aria: []
}
]
} as const;
it('a registered part answers partRef attrs with no hand-fed callback', () => {
const { result, cleanup } = withEffectRoot(() => {
const r = createSomaRuntime(registryMorfo, { dom });
const trigger = r.part('trigger', { id: state('trigger-1') });
return { r, trigger };
});
expect(result.trigger.props['aria-controls']).toBeUndefined();
const { cleanup: cleanupContent } = withEffectRoot(() =>
result.r.part('content', { id: state('content-live') })
);
flushSync();
expect(result.trigger.props['aria-controls']).toBe('content-live');
cleanupContent();
cleanup();
});
it('an explicit parts callback overrides the registry', () => {
const { result, cleanup } = withEffectRoot(() => {
const r = createSomaRuntime(registryMorfo, { dom });
const trigger = r.part('trigger', {
id: state('trigger-1'),
parts: { content: () => 'explicit-id' }
});
r.part('content', { id: state('content-live') });
return { trigger };
});
expect(result.trigger.props['aria-controls']).toBe('explicit-id');
cleanup();
});
it('repeated siblings keep the registry silent — only the explicit feed speaks', () => {
const { result, cleanup } = withEffectRoot(() => {
const r = createSomaRuntime(registryMorfo, { dom });
const trigger = r.part('trigger', { id: state('trigger-1') });
r.part('content', { id: state('content-a') });
r.part('content', { id: state('content-b') });
return { trigger };
});
expect(result.trigger.props['aria-controls']).toBeUndefined();
cleanup();
});
it('membership is reactive: a detached part retracts the attr, reattaching restores it', () => {
// `attachRef` is a Svelte attachment — membership follows the DOM
// attach/cleanup cycle, which a template normally drives. Here we
// run the attachment protocol by hand (the handle exposes it), the
// same calls Svelte makes on mount/unmount.
const el = document.createElement('div');
const contentRef = state<HTMLElement | null>(null);
const { result, cleanup } = withEffectRoot(() => {
const r = createSomaRuntime(registryMorfo, { dom });
const trigger = r.part('trigger', { id: state('trigger-1') });
const content = r.part('content', { id: state('content-live'), ref: contentRef });
return { trigger, content };
});
flushSync();
// Registered (constructed) counts as present even before attach.
expect(result.trigger.props['aria-controls']).toBe('content-live');
const attachment = result.content.attachment as Record<symbol, (n: HTMLElement) => () => void>;
const attachmentKey = Object.getOwnPropertySymbols(attachment)[0];
const detach = attachment[attachmentKey](el);
flushSync();
expect(result.trigger.props['aria-controls']).toBe('content-live');
detach();
flushSync();
expect(result.trigger.props['aria-controls']).toBeUndefined();
attachment[attachmentKey](el);
flushSync();
expect(result.trigger.props['aria-controls']).toBe('content-live');
cleanup();
});
});
it('the bag resolves the full contract and omits undefined-valued plans', () => {
// Re-signed for fase C2c (P0, audit 2026-08-26): the old pair of tests
// here exercised the retired `syncAttrs` effect (DOM writes on ref

@ -494,12 +494,19 @@ function hasSourceKeys(map: SourceMap | undefined): boolean {
function createBindingsScratch(
opts: Pick<SomaRuntimePartBaseOpts, 'states' | 'props' | 'parts'>,
sources: SomaRuntimeSources
sources: SomaRuntimeSources,
// The part's plans reference other parts (`deps.parts`), so the bucket
// must exist even when nobody hand-feeds `parts:` — the live registry
// fills it (see `readBindings`). Without this the bucket stayed
// `undefined` for every provider that forgot the callback and
// `part-present` evaluated false in silence.
hasPartDeps: boolean
): RuntimeBindingsScratch {
return {
states: hasSourceKeys(sources.states) || hasSourceKeys(opts.states) ? {} : undefined,
props: hasSourceKeys(sources.props) || hasSourceKeys(opts.props) ? {} : undefined,
parts: hasSourceKeys(sources.parts) || hasSourceKeys(opts.parts) ? {} : undefined,
parts:
hasSourceKeys(sources.parts) || hasSourceKeys(opts.parts) || hasPartDeps ? {} : undefined,
translations: sources.translate
};
}
@ -517,13 +524,30 @@ function readSourcesInto(
for (const key in map) bucket[key] = map[key]();
}
function readBindings(reg: PartRegistration, sources: SomaRuntimeSources): MorfoBindings {
function readBindings(
reg: PartRegistration,
sources: SomaRuntimeSources,
livePartId?: (part: string) => string | undefined
): MorfoBindings {
const bindings = reg.bindings;
clearBindingsBucket(bindings.states);
clearBindingsBucket(bindings.props);
clearBindingsBucket(bindings.parts);
// Live-registry DEFAULT for part refs (P1: `part-present` reads the
// registry, not a ceremony): every part this part's plans reference gets
// its id from the runtime's own registrations when the answer is
// unambiguous. Hand-fed `sources.parts` / `reg.parts` are read AFTER and
// overwrite — the explicit feed stays the override (and the only voice
// for repeated siblings, where the registry deliberately stays silent).
if (bindings.parts && livePartId) {
for (const p of reg.meta.deps.parts) {
const v = livePartId(p);
if (v !== undefined) bindings.parts[p] = v;
}
}
readSourcesInto(bindings.states, sources.states);
readSourcesInto(bindings.props, sources.props);
readSourcesInto(bindings.parts, sources.parts);
@ -564,6 +588,27 @@ export function createSomaRuntime<M extends Morfo>(
* part behaves exactly as before (null ref → the target checks throw/skip).
*/
const registrations = new Map<string, PartRegistration[]>();
// Registry membership lives in plain arrays (push/splice on attachment),
// which Svelte cannot observe — without this signal a sibling mounting or
// unmounting would leave every registry-backed binding STALE (the reason
// the pre-P1 design needed hand-fed callbacks). Bumped on every membership
// mutation; `resolveLivePartId` touches it so dependent render bags
// re-derive. Coarse-grained on purpose: part churn is rare, bags are cheap.
let partsRevision = $state(0);
/**
* The live registry as a bindings source: the part's current id when
* EXACTLY ONE registration answers for it, `undefined` otherwise. More
* than one live sibling (a repeated part) is ambiguous from up here —
* only the provider knows which sibling an attr should point at, so the
* registry stays silent and the hand-fed callback remains the only voice.
*/
function resolveLivePartId(part: string): string | undefined {
void partsRevision;
const bucket = registrations.get(part);
if (!bucket || bucket.length !== 1) return undefined;
return bucket[0].id.current || undefined;
}
const rootPropsScratch = hasSourceKeys(sources.props) ? {} : undefined;
// Which parts carry the `dir` stamp. Defaults to the provider root, which is
// where 44 of the 49 hand-written stamps sat; the five that paint elsewhere
@ -590,13 +635,14 @@ export function createSomaRuntime<M extends Morfo>(
states: opts.states,
props: opts.props,
parts: opts.parts,
bindings: createBindingsScratch(opts, sources),
bindings: createBindingsScratch(opts, sources, compiledPart.deps.parts.length > 0),
meta: compiledPart
};
const bucket = registrations.get(part) ?? [];
if (bucket.length === 0) registrations.set(part, bucket);
bucket.push(reg);
partsRevision++;
if (opts.ref) {
reg.attachment = attachRef(opts.ref, (el) => {
@ -610,6 +656,7 @@ export function createSomaRuntime<M extends Morfo>(
const index = bucket.indexOf(reg);
if (index >= 0) bucket.splice(index, 1);
}
partsRevision++;
untrack(() => opts.onRefChange?.(el));
});
}
@ -725,7 +772,7 @@ export function createSomaRuntime<M extends Morfo>(
let bindings: MorfoBindings | undefined;
for (const plan of reg.meta.dynamicAttrs) {
if (plan.consumerWins) continue;
bindings ??= readBindings(reg, sources);
bindings ??= readBindings(reg, sources, resolveLivePartId);
const value = evalAttrPlan(plan, bindings);
if (value !== undefined) props[plan.attr] = value;
}
@ -738,7 +785,7 @@ export function createSomaRuntime<M extends Morfo>(
// so the default re-renders on locale change.
for (const plan of reg.meta.dynamicAttrs) {
if (!plan.consumerWins) continue;
bindings ??= readBindings(reg, sources);
bindings ??= readBindings(reg, sources, resolveLivePartId);
const value = evalAttrPlan(plan, bindings);
if (value !== undefined) props[plan.attr] = value;
}
@ -763,7 +810,7 @@ export function createSomaRuntime<M extends Morfo>(
if (!matchesParsedKey(plan.key, event)) continue;
// Skip entries whose condition is currently falsy.
const reg = liveReg(part);
const bindings = reg ? readBindings(reg, sources) : EMPTY_BINDINGS;
const bindings = reg ? readBindings(reg, sources, resolveLivePartId) : EMPTY_BINDINGS;
if (!shouldEmitMorfoEntry(plan.condition, bindings)) continue;
const handler = actionHandlers?.[plan.action];

Loading…
Cancel
Save

Powered by TurnKey Linux.