diff --git a/src/arts/adom/README.md b/src/arts/adom/README.md index 20c15cfe2..52dd62da1 100644 --- a/src/arts/adom/README.md +++ b/src/arts/adom/README.md @@ -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( 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 diff --git a/src/arts/adom/active-dom.svelte.ts b/src/arts/adom/active-dom.svelte.ts index 504344add..ddccf4186 100644 --- a/src/arts/adom/active-dom.svelte.ts +++ b/src/arts/adom/active-dom.svelte.ts @@ -67,6 +67,7 @@ export interface ActiveDomRemoveNodeOptions { } export type ActiveDomListenerCleanup = () => void; +export type ActiveDomObserverCleanup = () => void; export interface ActiveDom { breakpoints: Active; @@ -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( 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; }, diff --git a/src/arts/adom/test/active-dom.test.ts b/src/arts/adom/test/active-dom.test.ts index 03b83dd65..655f39fbb 100644 --- a/src/arts/adom/test/active-dom.test.ts +++ b/src/arts/adom/test/active-dom.test.ts @@ -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 + }); + } + }); }); diff --git a/src/uix/README.md b/src/uix/README.md index 1e7243e4e..504dac598 100644 --- a/src/uix/README.md +++ b/src/uix/README.md @@ -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" diff --git a/src/uix/active-uix/active-uix.svelte.ts b/src/uix/active-uix/active-uix.svelte.ts index 9f01d1129..43d3d3654 100644 --- a/src/uix/active-uix/active-uix.svelte.ts +++ b/src/uix/active-uix/active-uix.svelte.ts @@ -258,6 +258,15 @@ function createDisabledActiveDom(): ActiveDom { listen() { return () => {}; }, + observeResize() { + return () => {}; + }, + observeMutation() { + return () => {}; + }, + observeIntersection() { + return () => {}; + }, activeElement() { return null; }, diff --git a/src/uix/active_architecture.md b/src/uix/active_architecture.md index 0ee6798e4..36ef99a9c 100644 --- a/src/uix/active_architecture.md +++ b/src/uix/active_architecture.md @@ -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. diff --git a/src/uix/soma/components/command/command-provider.svelte.ts b/src/uix/soma/components/command/command-provider.svelte.ts index e5bc7d634..0d777af5c 100644 --- a/src/uix/soma/components/command/command-provider.svelte.ts +++ b/src/uix/soma/components/command/command-provider.svelte.ts @@ -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(); }); } diff --git a/src/uix/soma/components/feed/feed-provider.svelte.ts b/src/uix/soma/components/feed/feed-provider.svelte.ts index f1feb8bcd..fe20fad22 100644 --- a/src/uix/soma/components/feed/feed-provider.svelte.ts +++ b/src/uix/soma/components/feed/feed-provider.svelte.ts @@ -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(); }); } diff --git a/src/uix/soma/components/navigation-menu/navigation-menu-provider.svelte.ts b/src/uix/soma/components/navigation-menu/navigation-menu-provider.svelte.ts index ee704b625..606bc17e4 100644 --- a/src/uix/soma/components/navigation-menu/navigation-menu-provider.svelte.ts +++ b/src/uix/soma/components/navigation-menu/navigation-menu-provider.svelte.ts @@ -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(); }; }); diff --git a/src/uix/soma/components/scroll-area/scroll-area-provider.svelte.ts b/src/uix/soma/components/scroll-area/scroll-area-provider.svelte.ts index 0a14b8c77..57b72a082 100644 --- a/src/uix/soma/components/scroll-area/scroll-area-provider.svelte.ts +++ b/src/uix/soma/components/scroll-area/scroll-area-provider.svelte.ts @@ -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(() => diff --git a/src/uix/soma/components/virtual-grid/virtual-grid-provider.svelte.ts b/src/uix/soma/components/virtual-grid/virtual-grid-provider.svelte.ts index 58a1bdd42..d4c09098e 100644 --- a/src/uix/soma/components/virtual-grid/virtual-grid-provider.svelte.ts +++ b/src/uix/soma/components/virtual-grid/virtual-grid-provider.svelte.ts @@ -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(); }; }); diff --git a/src/uix/soma/components/virtual-list/virtual-list-provider.svelte.ts b/src/uix/soma/components/virtual-list/virtual-list-provider.svelte.ts index ac96331ee..79e2ff829 100644 --- a/src/uix/soma/components/virtual-list/virtual-list-provider.svelte.ts +++ b/src/uix/soma/components/virtual-list/virtual-list-provider.svelte.ts @@ -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(); }); } diff --git a/src/uix/soma/layers/focus-scope.svelte.ts b/src/uix/soma/layers/focus-scope.svelte.ts index e3cb9a940..33ab223f3 100644 --- a/src/uix/soma/layers/focus-scope.svelte.ts +++ b/src/uix/soma/layers/focus-scope.svelte.ts @@ -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[] { diff --git a/src/uix/soma/layers/resize-observer.svelte.ts b/src/uix/soma/layers/resize-observer.svelte.ts index b56edcb47..2c0adba44 100644 --- a/src/uix/soma/layers/resize-observer.svelte.ts +++ b/src/uix/soma/layers/resize-observer.svelte.ts @@ -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(); }; }); }