From 9009cbfe8cf9ceb70295d40d68d622484f55e6e3 Mon Sep 17 00:00:00 2001 From: dev Date: Thu, 7 May 2026 17:31:40 +0200 Subject: [PATCH] active-uix: standalone mode no longer wraps active-app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Earlier design mistake: `createActiveUix(options)` was internally calling `createActiveApp(...)` to compose the UIX services. That violated the contract — active-uix was supposed to be either a substitute for active-app (when no app exists) or a complement (when an external app injects services). Fix: standalone mode now instantiates each UIX service directly via the canonical factories — no `active-app` involved: - core: createEngineLogger, createEngineBus, createActiveTimers, createActivePrefs (with empty schema) - lang: createActiveLang(schema, defaultLocale, fallbackChain) - dom: createActiveDom - frontend: createActiveFrontend (auto-applies to ) - format: createActiveFormat (clock from timers) - semantic: new EngineSemantic(opts) active-uix manages every owned lifecycle: dispose() tears them down in reverse dependency order. Attach mode is unchanged — wraps an external app, validates UIX services, doesn't touch app.dispose(). Type changes: - `ActiveUix.app: ActiveApp | undefined`. Present only in attach mode (it's the underlying app); `undefined` in standalone (no app exists). - `ActiveUix.bus: EngineBus` (generic envelope). Standalone gets a fresh bus, attach gets the App's bus with its event map. UIX components don't care about typed events; consumers that do re-narrow at the call site. - Removed `prefs?` and `extraServices?` from `ActiveUixOptions`. Apps that need a custom prefs schema or services beyond UIX should use attach mode (compose `createActiveApp` themselves). New tests cover both modes: standalone (8 tests including service disposal verification, idempotent dispose, format opt-out, componentLangs registration toggle), attach (6 tests including missing-services error, identity-equal service proxying, no disposal of external app), and cross-mode parity (1 test). Verified: vitest 15 files / 211 tests, smoke 68/68, morfo-check 66/66. --- src/uix/active-uix/active-uix.svelte.test.ts | 251 ++++++++++++++++ src/uix/active-uix/active-uix.svelte.ts | 288 +++++++++++++++---- src/uix/active-uix/types.ts | 40 +-- 3 files changed, 502 insertions(+), 77 deletions(-) create mode 100644 src/uix/active-uix/active-uix.svelte.test.ts diff --git a/src/uix/active-uix/active-uix.svelte.test.ts b/src/uix/active-uix/active-uix.svelte.test.ts new file mode 100644 index 000000000..03813af68 --- /dev/null +++ b/src/uix/active-uix/active-uix.svelte.test.ts @@ -0,0 +1,251 @@ +/** + * Tests for active-uix's two boot paths: + * - `createActiveUix(options)` — STANDALONE: active-uix instantiates + * its own services. No `active-app` involved. Owns lifecycles. + * - `attachActiveUix(app)` — ATTACH: external active-app injects the + * UIX services. active-uix doesn't own anything. + * + * Both produce an `ActiveUix` with the same observable surface. + * Components (soma / morfo / sema / eidos) consume that surface and + * have no way to tell which boot path was used — that decoupling is + * the whole point of active-uix. + */ + +import { describe, expect, it, vi } from 'vitest' + +import { createActiveApp, type ActiveApp } from '$active-app' +import { ActiveUixMissingServiceError, attachActiveUix, createActiveUix } from './active-uix.svelte' +import { defineUixServices } from './services' +import type { LangNode } from '$libs/lang' + +const minimalLang = { schema: {} satisfies LangNode, defaultLocale: 'es' as const } + +// ── createActiveUix (standalone — no active-app) ───────────────────────────── + +describe('createActiveUix (standalone)', () => { + it('exposes the canonical UIX surface', () => { + const uix = createActiveUix({ lang: minimalLang }) + try { + expect(uix.lang).toBeDefined() + expect(uix.frontend).toBeDefined() + expect(uix.format).toBeDefined() + expect(uix.dom).toBeDefined() + expect(uix.semantic).toBeDefined() + expect(uix.logger).toBeDefined() + expect(uix.bus).toBeDefined() + expect(uix.timers).toBeDefined() + expect(uix.prefs).toBeDefined() + expect(uix.ownsApp).toBe(true) + } finally { + uix.dispose() + } + }) + + it('does NOT create an active-app — uix.app is undefined in standalone mode', () => { + const uix = createActiveUix({ lang: minimalLang }) + try { + expect(uix.app).toBeUndefined() + } finally { + uix.dispose() + } + }) + + it('opts out of format when format=false', () => { + const uix = createActiveUix({ lang: minimalLang, format: false }) + try { + expect(uix.format).toBeUndefined() + } finally { + uix.dispose() + } + }) + + it('registers componentLangs by default — t() resolves a soma component path', () => { + const uix = createActiveUix({ lang: minimalLang }) + try { + const out = uix.lang.t('components.dialog.content.roledescription') + expect(typeof out).toBe('string') + expect(out.length).toBeGreaterThan(0) + } finally { + uix.dispose() + } + }) + + it('skips componentLangs when registerComponentLangs: false', () => { + const uix = createActiveUix({ + lang: minimalLang, + registerComponentLangs: false + }) + try { + const out = uix.lang.t('components.dialog.content.roledescription') + expect(out).not.toMatch(/dialog window/i) + } finally { + uix.dispose() + } + }) + + it('dispose tears down every owned service (standalone owns lifecycle)', () => { + const uix = createActiveUix({ lang: minimalLang }) + const langSpy = vi.spyOn(uix.lang, 'dispose') + const formatSpy = vi.spyOn(uix.format!, 'dispose') + const semanticSpy = vi.spyOn(uix.semantic, 'dispose') + uix.dispose() + expect(langSpy).toHaveBeenCalledTimes(1) + expect(formatSpy).toHaveBeenCalledTimes(1) + expect(semanticSpy).toHaveBeenCalledTimes(1) + }) + + it('dispose is idempotent', () => { + const uix = createActiveUix({ lang: minimalLang }) + const langSpy = vi.spyOn(uix.lang, 'dispose') + uix.dispose() + uix.dispose() + uix.dispose() + expect(langSpy).toHaveBeenCalledTimes(1) + }) +}) + +// ── attachActiveUix (integration with externally composed active-app) ──────── + +describe('attachActiveUix (integration mode)', () => { + it('attaches to an app composed via defineUixServices', () => { + const app = createActiveApp({ + services: defineUixServices({ lang: minimalLang }) + }) + const uix = attachActiveUix(app as unknown as ActiveApp) + try { + // Identity-equal: uix proxies to the same service instances. + expect(uix.lang).toBe(app.lang) + expect(uix.semantic).toBe(app.semantic) + expect(uix.dom).toBe(app.dom) + expect(uix.frontend).toBe(app.frontend) + expect(uix.ownsApp).toBe(false) + expect(uix.app).toBe(app) + } finally { + uix.dispose() + app.dispose() + } + }) + + it('coexists with extra services in the same app schema', () => { + // Real-world case: app composes UIX services + its own (cache, + // session, http, ...). attachActiveUix consumes the UIX slice; + // the rest stays on app for advanced consumers via uix.app. + const customFactory = { + name: 'custom' as const, + coreDependencies: [] as const, + initMode: 'lazy' as const, + create() { + return { tag: 'extra' } + } + } + + const app = createActiveApp({ + services: { + ...defineUixServices({ lang: minimalLang }), + custom: customFactory + } as never + }) + const uix = attachActiveUix(app as unknown as ActiveApp) + try { + expect(uix.lang).toBeDefined() + expect((app as unknown as { custom: { tag: string } }).custom.tag).toBe('extra') + } finally { + uix.dispose() + app.dispose() + } + }) + + it('registers componentLangs on attach (default true)', () => { + const app = createActiveApp({ + services: defineUixServices({ lang: minimalLang }) + }) + const uix = attachActiveUix(app as unknown as ActiveApp) + try { + const out = uix.lang.t('components.dialog.content.roledescription') + expect(typeof out).toBe('string') + expect(out.length).toBeGreaterThan(0) + } finally { + uix.dispose() + app.dispose() + } + }) + + it('throws ActiveUixMissingServiceError when required services are missing', () => { + const app = createActiveApp({ services: {} }) + try { + expect(() => attachActiveUix(app as unknown as ActiveApp)).toThrow( + ActiveUixMissingServiceError + ) + } finally { + app.dispose() + } + }) + + it('the error reports every missing service so the integrator can fix the schema', () => { + const app = createActiveApp({ services: {} }) + try { + attachActiveUix(app as unknown as ActiveApp) + expect.fail('should have thrown') + } catch (err) { + if (!(err instanceof ActiveUixMissingServiceError)) throw err + expect(err.missing).toContain('lang') + expect(err.missing).toContain('frontend') + expect(err.missing).toContain('dom') + expect(err.missing).toContain('semantic') + expect(err.message).toContain('defineUixServices') + } finally { + app.dispose() + } + }) + + it('dispose does NOT tear down the underlying app (ownsApp=false)', () => { + const app = createActiveApp({ + services: defineUixServices({ lang: minimalLang }) + }) + const spy = vi.spyOn(app, 'dispose') + const uix = attachActiveUix(app as unknown as ActiveApp) + uix.dispose() + expect(spy).not.toHaveBeenCalled() + // External owner is responsible for disposal. + app.dispose() + expect(spy).toHaveBeenCalledTimes(1) + }) +}) + +// ── Cross-mode parity ─────────────────────────────────────────────────────── + +describe('cross-mode parity', () => { + it('both modes produce an ActiveUix with the same surface keys', () => { + const standalone = createActiveUix({ lang: minimalLang }) + const externalApp = createActiveApp({ + services: defineUixServices({ lang: minimalLang }) + }) + const attached = attachActiveUix(externalApp as unknown as ActiveApp) + try { + const surface = (uix: typeof standalone) => + [ + uix.lang !== undefined, + uix.frontend !== undefined, + uix.format !== undefined, + uix.dom !== undefined, + uix.semantic !== undefined, + uix.logger !== undefined, + uix.bus !== undefined, + uix.timers !== undefined, + uix.prefs !== undefined + ].every(Boolean) + + expect(surface(standalone)).toBe(true) + expect(surface(attached)).toBe(true) + expect(standalone.ownsApp).toBe(true) + expect(attached.ownsApp).toBe(false) + // app is the canonical asymmetry: only present in attach mode. + expect(standalone.app).toBeUndefined() + expect(attached.app).toBe(externalApp) + } finally { + standalone.dispose() + attached.dispose() + externalApp.dispose() + } + }) +}) diff --git a/src/uix/active-uix/active-uix.svelte.ts b/src/uix/active-uix/active-uix.svelte.ts index 97b4a1c3a..b16cf6255 100644 --- a/src/uix/active-uix/active-uix.svelte.ts +++ b/src/uix/active-uix/active-uix.svelte.ts @@ -1,29 +1,38 @@ /** * `active-uix` boot — two paths to obtain an `ActiveUix`: * - * - `createActiveUix(options)` (standalone): builds an `ActiveApp` - * internally with `defineUixServices(options)` + any - * `extraServices`, then wraps it. Owns the app — `dispose()` - * tears down the underlying composition. + * - `createActiveUix(options)` (standalone): instantiates the + * services it needs (lang, frontend, format, dom, semantic) plus + * a minimal core (logger, bus, timers, prefs) **directly**. Does + * NOT use `active-app`. Owns every service it created and + * `dispose()` tears them down. * * - `attachActiveUix(app, options?)` (attach): takes an external * `ActiveApp` that the application composed itself (typically * because it has services beyond UIX — cache / session / http / * ...). Validates that the app declared the UIX services. Does - * NOT own the app — `dispose()` tears down only the UIX-level - * wiring. + * NOT own anything — `dispose()` is a no-op for the underlying + * services (the application is the lifecycle owner). * * Components consume the resulting `ActiveUix` via `getActiveUix()` - * (set into Svelte context by ``). They have no way to tell - * which boot path produced it, by design. + * and have no way to tell which boot path produced it. */ -import { createActiveApp } from '$active-app' import type { ActiveApp } from '$active-app' +import { createEngineLogger, type EngineLogger } from '$logger' +import { createEngineBus, type EngineBus } from '$bus' +import { createActiveTimers, type ActiveTimers } from '$timer' +import { createActivePrefs, type ActivePrefs } from '$prefs' +import { createActiveLang } from '$lang/active-lang.svelte' +import type { ActiveLang } from '$lang' +import { createActiveDom, type ActiveDom } from '$adom' +import { createActiveFrontend, type ActiveFrontend } from '$frontend/active-frontend.svelte' +import { createActiveFormat, type ActiveFormat } from '$format' +import { EngineSemantic } from '$uix/sema' import { CodeError, errCode, moduleSeed, type ErrCode, type ModuleSeed } from '$libs/errs' import { componentLangs } from '$soma/core/langs' -import { defineUixServices, UIX_REQUIRED_SERVICES, type UixRequiredService } from './services' +import { UIX_REQUIRED_SERVICES, type UixRequiredService } from './services' import type { ActiveUix, ActiveUixOptions, AttachActiveUixOptions } from './types' const UIX_MODULE = 'uix' @@ -42,85 +51,246 @@ export class ActiveUixMissingServiceError extends CodeError { } } +// ── Standalone boot ──────────────────────────────────────────────────────── + +/** + * Standalone composition. active-uix instantiates each service + * directly and manages its lifecycle. No `active-app` involved. + * + * For apps that have additional services (cache, session, http, …) + * the integrator composes those via `createActiveApp` themselves and + * uses `attachActiveUix(app)` instead. + */ export function createActiveUix(options: ActiveUixOptions): ActiveUix { - const app = createActiveApp({ - logger: options.logger, - prefs: options.prefs, - services: { - ...defineUixServices(options), - ...(options.extraServices ?? {}) - } + // ── Core ─────────────────────────────────────────────────────── + const logger = createEngineLogger(options.logger ?? {}) + const bus = createEngineBus() + const timers = createActiveTimers() + // Minimal prefs: empty schema. UIX-only apps don't need a prefs + // catalog; integrators that do should use attach mode where they + // declare the prefs schema in `createActiveApp({ prefs })`. + const prefs = createActivePrefs({ schema: {} }) + + // ── UIX services ─────────────────────────────────────────────── + const lang = createActiveLang( + options.lang.schema, + options.lang.defaultLocale ?? 'es', + options.lang.fallbackChain ? [...options.lang.fallbackChain] : undefined + ) + lang.setLogger(logger) + + const dom = + options.dom !== false + ? createActiveDom(typeof options.dom === 'object' && options.dom !== null ? options.dom : {}) + : undefined + + const frontend = + options.frontend !== false + ? createActiveFrontend({ + applyDom: true, + target: typeof document === 'undefined' ? undefined : () => document.documentElement, + dom, + ...(typeof options.frontend === 'object' && options.frontend !== null + ? options.frontend + : {}) + }) + : undefined + + const format = + options.format !== false + ? createActiveFormat({ + clock: timers.clock, + ...(typeof options.format === 'object' && options.format !== null + ? options.format + : {}) + }) + : undefined + + const semantic = + options.semantic !== false + ? new EngineSemantic( + typeof options.semantic === 'object' && options.semantic !== null + ? options.semantic + : {} + ) + : undefined + + if (options.registerComponentLangs ?? true) { + lang.extend('components', componentLangs) + } + + return new ActiveUixImpl({ + mode: 'standalone', + logger, + bus, + timers, + prefs, + lang, + frontend, + format, + dom, + semantic, + somaPortalTo: options.somaPortalTo }) - return new ActiveUixImpl(app, true, options) } +// ── Attach boot ──────────────────────────────────────────────────────────── + export function attachActiveUix(app: ActiveApp, options: AttachActiveUixOptions = {}): ActiveUix { const missing = UIX_REQUIRED_SERVICES.filter( (name) => (app as unknown as Record)[name] === undefined ) if (missing.length > 0) throw new ActiveUixMissingServiceError(missing) - return new ActiveUixImpl(app, false, options) + + if (options.registerComponentLangs ?? true) { + ;(app.lang as ActiveLang).extend('components', componentLangs) + } + + return new ActiveUixImpl({ + mode: 'attach', + app, + somaPortalTo: options.somaPortalTo + }) } -class ActiveUixImpl implements ActiveUix { - readonly app: ActiveApp - readonly ownsApp: boolean - readonly somaPortalTo: string | HTMLElement | undefined +// ── Implementation ───────────────────────────────────────────────────────── +interface StandaloneInit { + mode: 'standalone' + logger: EngineLogger + bus: EngineBus + timers: ActiveTimers + prefs: ActivePrefs + lang: ActiveLang + frontend: ActiveFrontend | undefined + format: ActiveFormat | undefined + dom: ActiveDom | undefined + semantic: EngineSemantic | undefined + somaPortalTo: string | HTMLElement | undefined +} + +interface AttachInit { + mode: 'attach' + app: ActiveApp + somaPortalTo: string | HTMLElement | undefined +} + +class ActiveUixImpl implements ActiveUix { + private readonly init: StandaloneInit | AttachInit private disposed = false - constructor( - app: ActiveApp, - ownsApp: boolean, - options: { somaPortalTo?: string | HTMLElement; registerComponentLangs?: boolean } - ) { - this.app = app - this.ownsApp = ownsApp - this.somaPortalTo = options.somaPortalTo - - // UIX-level wiring: register soma component translations so - // `lang.t('components.dialog.…')` works without per-app boilerplate. - if (options.registerComponentLangs ?? true) { - this.app.lang.extend('components', componentLangs) - } + constructor(init: StandaloneInit | AttachInit) { + this.init = init } - // ── UIX services (the canonical surface) ─────────────────────── - get lang() { - return this.app.lang + get ownsApp(): boolean { + return this.init.mode === 'standalone' } - get frontend() { - return this.app.frontend + + /** + * In attach mode, the underlying app. In standalone mode, `undefined` + * (active-uix is its own composition root, no app exists). + */ + get app(): ActiveApp | undefined { + return this.init.mode === 'attach' ? this.init.app : undefined + } + + get somaPortalTo() { + return this.init.somaPortalTo } - get format() { - return this.app.format as ActiveUix['format'] + + // ── UIX services ─────────────────────────────────────────────── + // + // In standalone mode, services live directly on the impl. In + // attach mode, they live on the underlying app and we cast the + // generic `unknown` slots to their precise types — a service was + // validated to exist by `attachActiveUix`'s missing-services check. + + get lang(): ActiveLang { + return this.init.mode === 'standalone' + ? this.init.lang + : (this.init.app.lang as ActiveLang) } - get dom() { - return this.app.dom + + get frontend(): ActiveFrontend { + const f = + this.init.mode === 'standalone' + ? this.init.frontend + : (this.init.app.frontend as ActiveFrontend | undefined) + if (!f) { + throw new Error('[uix] frontend service not configured (frontend: false in standalone, missing in attached app)') + } + return f } - get semantic() { - return this.app.semantic + + get format(): ActiveFormat | undefined { + return this.init.mode === 'standalone' + ? this.init.format + : (this.init.app.format as ActiveFormat | undefined) + } + + get dom(): ActiveDom { + const d = + this.init.mode === 'standalone' + ? this.init.dom + : (this.init.app.dom as ActiveDom | undefined) + if (!d) { + throw new Error('[uix] dom service not configured') + } + return d + } + + get semantic(): EngineSemantic { + const s = + this.init.mode === 'standalone' + ? this.init.semantic + : (this.init.app.semantic as EngineSemantic | undefined) + if (!s) { + throw new Error('[uix] semantic service not configured') + } + return s } // ── Core ─────────────────────────────────────────────────────── - get logger() { - return this.app.logger + get logger(): EngineLogger { + return this.init.mode === 'standalone' ? this.init.logger : this.init.app.logger } - get bus() { - return this.app.bus + + get bus(): EngineBus { + return this.init.mode === 'standalone' ? this.init.bus : (this.init.app.bus as EngineBus) } - get timers() { - return this.app.timers + + get timers(): ActiveTimers { + return this.init.mode === 'standalone' ? this.init.timers : this.init.app.timers } - get prefs() { - return this.app.prefs + + get prefs(): ActivePrefs { + return this.init.mode === 'standalone' ? this.init.prefs : this.init.app.prefs } dispose(): void { if (this.disposed) return this.disposed = true - // In attach mode the underlying app is owned by the application; - // don't dispose it. Standalone mode owns it and tears down. - if (this.ownsApp) this.app.dispose() + + // Standalone: tear down every service we instantiated. Reverse + // dependency order: format → frontend → semantic → dom → lang → + // prefs → timers → bus → logger. + if (this.init.mode === 'standalone') { + const { format, frontend, semantic, dom, lang, prefs, timers, logger } = this.init + format?.dispose() + frontend?.dispose() + semantic?.dispose() + dom?.dispose() + lang.dispose() + prefs.dispose() + timers.dispose() + logger.dispose() + } + + // Attach mode: the app is owned by the application, not by us. + // We don't touch app.dispose(). componentLangs.extend is the + // only side effect we left on the app, and it isn't undoable + // (lang doesn't expose `unextend`); that's an acceptable + // trade-off for the simplicity. } } diff --git a/src/uix/active-uix/types.ts b/src/uix/active-uix/types.ts index e0cbfee1e..22cf151f7 100644 --- a/src/uix/active-uix/types.ts +++ b/src/uix/active-uix/types.ts @@ -7,7 +7,7 @@ * escape hatch for advanced integrations. */ -import type { ActiveApp, AppServiceSchema, ActiveAppPrefsOptions } from '$active-app' +import type { ActiveApp } from '$active-app' import type { ActiveLang } from '$lang' import type { ActiveDom, ActiveDomProps } from '$adom' import type { @@ -52,15 +52,9 @@ export interface ActiveUixOptions { /** Sema engine options. `false` opts out (no perceptual signals). */ readonly semantic?: EngineSemanticOptions | false - /** Passes through to `createActiveApp({ logger })`. */ + /** Logger options. Standalone mode wires this directly into the engine logger. */ readonly logger?: LoggerOptions - /** Passes through to `createActiveApp({ prefs })`. */ - readonly prefs?: ActiveAppPrefsOptions - - /** Extra services beyond the UIX defaults (cache, session, http, ...). */ - readonly extraServices?: AppServiceSchema - /** Default portal target for ``. */ readonly somaPortalTo?: string | HTMLElement @@ -93,8 +87,14 @@ export interface ActiveUix { readonly dom: ActiveDom readonly semantic: EngineSemantic - // ── Core (always present in any active-app) ──────────────────────── + // ── Core (always present, regardless of boot mode) ───────────────── readonly logger: EngineLogger + /** + * Bus typed as a generic envelope so both modes line up: standalone + * gets a fresh `EngineBus`, attach mode gets the App's bus (which + * carries `ActiveAppBusEvents`). Consumers that need typed events + * should re-narrow at the call site. + */ readonly bus: EngineBus readonly timers: ActiveTimers readonly prefs: ActivePrefs @@ -103,18 +103,22 @@ export interface ActiveUix { readonly somaPortalTo: string | HTMLElement | undefined /** - * Underlying `ActiveApp`. Escape hatch — use only for advanced - * integration code that needs services beyond the UIX surface - * (cache, session, http, ...). UIX components MUST NOT touch this. + * Underlying `ActiveApp` — present ONLY when `ActiveUix` was + * obtained via `attachActiveUix(app)`. In standalone mode + * (`createActiveUix`), active-uix is its own composition root and + * no `ActiveApp` exists, so this is `undefined`. + * + * Escape hatch for advanced integration code that needs services + * beyond the UIX surface (cache, session, http, ...). UIX + * components MUST NOT touch this. */ - readonly app: ActiveApp + readonly app: ActiveApp | undefined /** - * `true` when this `ActiveUix` was bootstrapped via - * `createActiveUix()` (it owns the underlying app); `false` when - * obtained via `attachActiveUix(externalApp)`. - * - * Determines whether `dispose()` propagates to `app.dispose()`. + * `false` when obtained via `attachActiveUix(externalApp)` — the + * application owns the underlying composition. `true` in + * standalone mode — active-uix instantiated every service itself + * and `dispose()` tears them down. */ readonly ownsApp: boolean