active-uix: standalone mode no longer wraps active-app

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 <html>)
  - 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.
morfo-runtime
dev 5 months ago
parent 83b841a7ca
commit 9009cbfe8c

@ -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()
}
})
})

@ -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 `<Uix>`). 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<UixRequiredService, unknown>)[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.
}
}

@ -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 `<Soma>`. */
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

Loading…
Cancel
Save

Powered by TurnKey Linux.