Route Soma observers through ActiveDom

active-uix
dev 5 months ago
parent 0021826413
commit 1d34200f46

@ -129,6 +129,21 @@ export interface ActiveDom {
event: string | readonly string[],
handler: EventListener
): () => void;
observeResize(
target: Element,
callback: ResizeObserverCallback,
options?: ResizeObserverOptions
): () => void;
observeMutation(
target: Node,
callback: MutationCallback,
options: MutationObserverInit
): () => void;
observeIntersection(
target: Element,
callback: IntersectionObserverCallback,
options?: IntersectionObserverInit
): () => void;
activeElement(node?: Element | Window | Node | Document | null): Element | null;
query<T extends Element = Element>(
selector: string,
@ -220,8 +235,9 @@ Reglas:
listener a la primera lectura del viewport — importar `$adom` no aloca
estado reactivo si nadie consume el viewport.
- `apply` solo escribe atributos; no interpreta semantica ni eventos
- listeners de `document` / `window` y acciones imperativas transversales van
por `ActiveDom`
- listeners de `document` / `window`, observers (`ResizeObserver`,
`MutationObserver`, `IntersectionObserver`) y acciones imperativas
transversales van por `ActiveDom`
- lecturas locales de un elemento propio (`getBoundingClientRect`, `contains`,
`closest`, `clientWidth`, `scrollTop`) siguen siendo responsabilidad del
componente; envolverlas en `ActiveDom` seria ruido
@ -266,7 +282,8 @@ top-level `window`).
- `EngineSemantic`
- broker de eventos
- reflector de `data-event*`
- hub de `MutationObserver`
- scheduler de observers compartidos con cache global; solo expone factories
scoped a la ventana propietaria del target
- sistema de theme
- sistema de modal, backdrop o inert
- reemplazo de `$libs/dom`
@ -301,10 +318,8 @@ Si mañana `ActiveDom` refleja eventos al DOM, será como consumidor de
### Theme
El theme no pertenece a `dom`.
Va en `app.presentation`, porque es estado de presentación de aplicación, no una
primitive DOM.
El theme no pertenece a `dom`. En UIX, `ActiveEidos` posee `data-theme`,
`data-mode` y `data-density`; `ActiveDom` solo recibe la mutación ya resuelta.
### Air y Terra
@ -325,11 +340,12 @@ Lo que ya está cerrado:
- `breakpoints`
- `currentBreakpoint`
- resolución responsive
- listeners, observers y acciones imperativas scoped a la ventana propietaria
del target
Lo que queda para fases posteriores, si de verdad hace falta:
- reflexión de eventos semánticos al DOM
- observers compartidos
- APIs por `Document` o `ShadowRoot`
- introspección/diagnóstico de runtime más rica

@ -67,6 +67,7 @@ export interface ActiveDomRemoveNodeOptions {
}
export type ActiveDomListenerCleanup = () => void;
export type ActiveDomObserverCleanup = () => void;
export interface ActiveDom {
breakpoints: Active<Breakpoints>;
@ -110,6 +111,21 @@ export interface ActiveDom {
handler: EventListener,
options?: boolean | AddEventListenerOptions
): ActiveDomListenerCleanup;
observeResize(
target: Element,
callback: ResizeObserverCallback,
options?: ResizeObserverOptions
): ActiveDomObserverCleanup;
observeMutation(
target: Node,
callback: MutationCallback,
options: MutationObserverInit
): ActiveDomObserverCleanup;
observeIntersection(
target: Element,
callback: IntersectionObserverCallback,
options?: IntersectionObserverInit
): ActiveDomObserverCleanup;
activeElement(node?: Element | Window | Node | Document | null): Element | null;
query<T extends Element = Element>(
selector: string,
@ -219,6 +235,50 @@ export function createActiveDom(props: ActiveDomProps = {}): ActiveDom {
};
}
function observeResize(
target: Element,
callback: ResizeObserverCallback,
options?: ResizeObserverOptions
): ActiveDomObserverCleanup {
const ResizeObserverCtor = (
resolveWindow(target) as unknown as { ResizeObserver?: typeof ResizeObserver }
).ResizeObserver;
if (typeof ResizeObserverCtor !== 'function') return () => {};
const observer = new ResizeObserverCtor(callback);
observer.observe(target, options);
return () => observer.disconnect();
}
function observeMutation(
target: Node,
callback: MutationCallback,
options: MutationObserverInit
): ActiveDomObserverCleanup {
const MutationObserverCtor = (
resolveWindow(target) as unknown as { MutationObserver?: typeof MutationObserver }
).MutationObserver;
if (typeof MutationObserverCtor !== 'function') return () => {};
const observer = new MutationObserverCtor(callback);
observer.observe(target, options);
return () => observer.disconnect();
}
function observeIntersection(
target: Element,
callback: IntersectionObserverCallback,
options: IntersectionObserverInit = {}
): ActiveDomObserverCleanup {
const IntersectionObserverCtor = (
resolveWindow(options.root ?? target) as unknown as {
IntersectionObserver?: typeof IntersectionObserver;
}
).IntersectionObserver;
if (typeof IntersectionObserverCtor !== 'function') return () => {};
const observer = new IntersectionObserverCtor(callback, options);
observer.observe(target);
return () => observer.disconnect();
}
return {
breakpoints,
viewport: viewportReadonly,
@ -320,6 +380,9 @@ export function createActiveDom(props: ActiveDomProps = {}): ActiveDom {
}
},
listen,
observeResize,
observeMutation,
observeIntersection,
activeElement(node): Element | null {
return resolveDocument(node).activeElement;
},

@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { readableActive } from '$reactive';
import { createActiveDom } from '../active-dom.svelte';
@ -128,4 +128,69 @@ describe('ActiveDom', () => {
dom.cancelFrame(id);
expect(fired).toBe(false);
});
it('wraps observers through the owning window', () => {
const dom = createActiveDom();
const target = document.createElement('div');
document.body.appendChild(target);
const originalResizeObserver = window.ResizeObserver;
const originalMutationObserver = window.MutationObserver;
const originalIntersectionObserver = window.IntersectionObserver;
const resizeObserve = vi.fn();
const resizeDisconnect = vi.fn();
const mutationObserve = vi.fn();
const mutationDisconnect = vi.fn();
const intersectionObserve = vi.fn();
const intersectionDisconnect = vi.fn();
class MockResizeObserver {
constructor(_callback: ResizeObserverCallback) {}
observe = resizeObserve;
disconnect = resizeDisconnect;
}
class MockMutationObserver {
constructor(_callback: MutationCallback) {}
observe = mutationObserve;
disconnect = mutationDisconnect;
}
class MockIntersectionObserver {
constructor(_callback: IntersectionObserverCallback, _options?: IntersectionObserverInit) {}
observe = intersectionObserve;
disconnect = intersectionDisconnect;
}
Object.assign(window, {
ResizeObserver: MockResizeObserver,
MutationObserver: MockMutationObserver,
IntersectionObserver: MockIntersectionObserver
});
try {
const cleanupResize = dom.observeResize(target, () => {});
expect(resizeObserve).toHaveBeenCalledWith(target, undefined);
cleanupResize();
expect(resizeDisconnect).toHaveBeenCalledOnce();
const mutationOptions = { childList: true };
const cleanupMutation = dom.observeMutation(target, () => {}, mutationOptions);
expect(mutationObserve).toHaveBeenCalledWith(target, mutationOptions);
cleanupMutation();
expect(mutationDisconnect).toHaveBeenCalledOnce();
const cleanupIntersection = dom.observeIntersection(target, () => {}, { threshold: 1 });
expect(intersectionObserve).toHaveBeenCalledWith(target);
cleanupIntersection();
expect(intersectionDisconnect).toHaveBeenCalledOnce();
} finally {
Object.assign(window, {
ResizeObserver: originalResizeObserver,
MutationObserver: originalMutationObserver,
IntersectionObserver: originalIntersectionObserver
});
}
});
});

@ -378,9 +378,11 @@ quieren escuchar, y por eso introduce `adom` como servicio de app.
La regla no es "todo acceso DOM pasa por ADom". La regla es mas precisa:
escrituras gestionadas por UIX, listeners de `document/window`, consultas
globales y acciones imperativas transversales pasan por `ActiveDom`. Las
lecturas locales de un elemento que el componente ya posee (`contains`,
`closest`, `getBoundingClientRect`, `scrollTop`) se quedan en el componente.
globales, observers (`ResizeObserver`, `MutationObserver`,
`IntersectionObserver`) y acciones imperativas transversales pasan por
`ActiveDom`. Las lecturas locales de un elemento que el componente ya posee
(`contains`, `closest`, `getBoundingClientRect`, `scrollTop`) se quedan en el
componente.
### 3.5 La app compone servicios, no "super componentes"

@ -258,6 +258,15 @@ function createDisabledActiveDom(): ActiveDom {
listen() {
return () => {};
},
observeResize() {
return () => {};
},
observeMutation() {
return () => {};
},
observeIntersection() {
return () => {};
},
activeElement() {
return null;
},

@ -94,7 +94,9 @@ Hallazgos P1 resueltos en esta pasada:
modeSource, applyDom: true })` y no escribe `uix.prefs.theme`.
- Ownership DOM P1 queda cerrado: los nodos gestionados por UIX
(live regions, descripciones ocultas) usan `ActiveDom.writeNode/writeText`
y el bloqueo de seleccion de texto usa `ActiveDom.apply`.
y el bloqueo de seleccion de texto usa `ActiveDom.apply`. Los observers de
Soma (`ResizeObserver`, `MutationObserver`, `IntersectionObserver`) se crean
mediante `ActiveDom` para respetar `targetWindow`/iframes/disabledDom.
- `SoundChannel` ya no registra listeners globales en el constructor; el
priming ocurre en `prepare()` para señales sonoras.
- Sema ya no propaga `motion`, `color` ni `presence` en `EffectiveSignature`;
@ -864,8 +866,8 @@ Para evitar mission creep, conviene fijar lo que UIX **no quiere ser**:
como `EidosConfigDocument` versionado; `ActiveEidos` puede bootear desde ese
documento y exponerlo de nuevo via `toDocument()` / `serialize()`.
- `adom` — `dom.apply(change)`, `dom.remove(target, names)`. Toda la
superficie reactiva (viewport, breakpoints, BodyScrollLock,
DOMContext, RovingFocusGroup) ya estable.
superficie reactiva (viewport, breakpoints, listeners, observers,
BodyScrollLock, DOMContext, RovingFocusGroup) ya estable.
- `active-uix` + `active-app` — composition root con dos modos de
boot (`createActiveUix(options)` / `attachActiveUix(activeApp)`).
Props bridge V2 (`OptsFromProps` + `bindProps`) operativo.

@ -21,7 +21,6 @@ import type { CommandFilterFn, CommandSnippetProps } from './types';
import { commandMorfo } from '../../../morfo/components/command';
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
// ── Registry shapes ──────────────────────────────────────────────────────────
interface ItemEntry {
@ -574,14 +573,12 @@ export class CommandViewportProvider {
$effect(() => {
const el = opts.ref.current;
if (!el || typeof ResizeObserver === 'undefined') return;
if (!el) return;
const child = el.firstElementChild as HTMLElement | null;
if (!child) return;
const ro = new ResizeObserver(([entry]) => {
return provider.soma.dom.observeResize(child, ([entry]) => {
this.height = entry?.borderBoxSize?.[0]?.blockSize ?? entry?.contentRect.height;
});
ro.observe(child);
return () => ro.disconnect();
});
}

@ -409,12 +409,13 @@ export class FeedSentinelProvider {
$effect(() => {
const el = opts.ref.current;
const disabled = opts.disabled.current;
if (!el || disabled || typeof IntersectionObserver === 'undefined') return;
if (!el || disabled) return;
const rootMargin = opts.rootMargin.current;
const threshold = opts.threshold.current;
const rootOpt = opts.root.current;
const observer = new IntersectionObserver(
return this.soma.dom.observeIntersection(
el,
(entries) => {
for (const entry of entries) {
if (entry.target !== el) continue;
@ -427,13 +428,11 @@ export class FeedSentinelProvider {
},
{
// `root: Document` isn't spec'd for IO — coerce to null.
root: rootOpt instanceof Document ? null : rootOpt,
root: rootOpt?.nodeType === 9 ? null : rootOpt,
rootMargin,
threshold
}
);
observer.observe(el);
return () => observer.disconnect();
});
}

@ -703,16 +703,16 @@ export class NavigationMenuIndicatorProvider {
};
};
update();
const ro = new ResizeObserver(update);
ro.observe(triggerEl);
ro.observe(root);
const cleanupTriggerResize = this.provider.soma.dom.observeResize(triggerEl, update);
const cleanupRootResize = this.provider.soma.dom.observeResize(root, update);
const cleanupResize = this.provider.soma.dom.listen(
this.provider.soma.dom.getWindow(root),
'resize',
update
);
return () => {
ro.disconnect();
cleanupTriggerResize();
cleanupRootResize();
cleanupResize();
};
});

@ -4,7 +4,6 @@ import { readableActive, state, type Active, type ActiveProps } from '$libs/reac
import type { Direction } from '../../types';
import { Soma } from '../../core/soma.svelte';
import { SCROLL_AREA_LANGS } from './langs';
import { isBrowser } from '$adom';
import { scrollAreaMorfo } from '../../../morfo/components/scroll-area';
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
@ -49,7 +48,7 @@ export class ScrollAreaProvider {
scrollTop = $state(0);
scrollLeft = $state(0);
private resizeObserver: ResizeObserver | null = null;
private resizeCleanups: Array<() => void> = [];
private constructor(opts: ScrollAreaOpts) {
this.opts = opts;
@ -102,7 +101,7 @@ export class ScrollAreaProvider {
this.viewportRef.current = el;
this.cleanupResize();
if (!el || !isBrowser) return;
if (!el) return;
const update = () => {
this.viewportWidth = el.clientWidth;
@ -113,10 +112,9 @@ export class ScrollAreaProvider {
update();
this.resizeObserver = new ResizeObserver(update);
this.resizeObserver.observe(el);
this.resizeCleanups.push(this.soma.dom.observeResize(el, update));
if (el.firstElementChild) {
this.resizeObserver.observe(el.firstElementChild);
this.resizeCleanups.push(this.soma.dom.observeResize(el.firstElementChild, update));
}
}
@ -129,8 +127,8 @@ export class ScrollAreaProvider {
}
private cleanupResize() {
this.resizeObserver?.disconnect();
this.resizeObserver = null;
for (const cleanup of this.resizeCleanups) cleanup();
this.resizeCleanups = [];
}
readonly props = $derived.by(() =>

@ -241,15 +241,14 @@ export class VirtualGridViewportProvider {
updateSize();
updateScroll();
const ro = new ResizeObserver(updateSize);
ro.observe(el);
const cleanupResize = this.provider.soma.dom.observeResize(el, updateSize);
const cleanupScroll = this.provider.soma.dom.listen(el, 'scroll', updateScroll, {
passive: true
});
return () => {
ro.disconnect();
cleanupResize();
cleanupScroll();
};
});

@ -339,15 +339,14 @@ export class VirtualListViewportProvider {
updateSize();
updateScroll();
const ro = new ResizeObserver(updateSize);
ro.observe(el);
const cleanupResizeObserver = this.provider.soma.dom.observeResize(el, updateSize);
const cleanupScroll = this.provider.soma.dom.listen(el, 'scroll', updateScroll, {
passive: true
});
return () => {
ro.disconnect();
cleanupResizeObserver();
cleanupScroll();
};
});
@ -430,14 +429,17 @@ export class VirtualListWindowViewportProvider {
// Watch the list's own size too — layout shifts above move the
// list's top edge, which changes the effective scrollOffset.
const ro = new ResizeObserver(updateScroll);
ro.observe(el);
ro.observe(this.provider.soma.dom.getDocument(el).documentElement);
const cleanupElementResize = this.provider.soma.dom.observeResize(el, updateScroll);
const cleanupDocumentResize = this.provider.soma.dom.observeResize(
this.provider.soma.dom.getDocument(el).documentElement,
updateScroll
);
return () => {
cleanupScroll();
cleanupResize();
ro.disconnect();
cleanupElementResize();
cleanupDocumentResize();
};
});
}
@ -486,7 +488,7 @@ export class VirtualListItemProvider {
if (this.provider.opts.itemSize.current !== undefined) return;
const horizontal = this.provider.isHorizontal;
const ro = new ResizeObserver((entries) => {
return this.provider.soma.dom.observeResize(el, (entries) => {
const entry = entries[0];
if (!entry) return;
// Prefer borderBoxSize (layout-precise); fall back to rect.
@ -502,8 +504,6 @@ export class VirtualListItemProvider {
this.provider.measureItem(this.opts.index.current, measured);
}
});
ro.observe(el);
return () => ro.disconnect();
});
}

@ -199,18 +199,22 @@ export class FocusScope {
this.opts.dom.listen(container, 'keydown', handleKeydown)
);
const observer = new MutationObserver(() => {
const lastFocused = this.manager.getFocusMemory(this);
if (lastFocused && !container.contains(lastFocused)) {
const el = this.getFirstTabbable() || this.getAllFocusables()[0];
if (el) {
this.opts.dom.focus(el);
this.manager.setFocusMemory(this, el);
} else this.opts.dom.focus(container);
}
});
observer.observe(container, { childList: true, subtree: true });
this.cleanupFns.push(() => observer.disconnect());
this.cleanupFns.push(
this.opts.dom.observeMutation(
container,
() => {
const lastFocused = this.manager.getFocusMemory(this);
if (lastFocused && !container.contains(lastFocused)) {
const el = this.getFirstTabbable() || this.getAllFocusables()[0];
if (el) {
this.opts.dom.focus(el);
this.manager.setFocusMemory(this, el);
} else this.opts.dom.focus(container);
}
},
{ childList: true, subtree: true }
)
);
}
getTabbables(): HTMLElement[] {

@ -13,15 +13,14 @@ export class ResizeObserver$ {
const el = node();
if (!el) return;
const observer = new ResizeObserver(() => {
const cleanupResize = dom.observeResize(el, () => {
dom.cancelFrame(rAF, el);
rAF = dom.requestFrame(onResize, el);
});
observer.observe(el);
return () => {
dom.cancelFrame(rAF, el);
observer.unobserve(el);
cleanupResize();
};
});
}

Loading…
Cancel
Save

Powered by TurnKey Linux.