diff --git a/src/valdi_modules/src/valdi/valdi_core/src/Renderer.ts b/src/valdi_modules/src/valdi/valdi_core/src/Renderer.ts index 80559371..347ffce8 100644 --- a/src/valdi_modules/src/valdi/valdi_core/src/Renderer.ts +++ b/src/valdi_modules/src/valdi/valdi_core/src/Renderer.ts @@ -87,6 +87,7 @@ interface RenderedElement { id: number; nodePrototype: NodePrototype | undefined; attributes: StringMap; + directionWasSetDuringRender?: boolean; wasVisibleOnce?: boolean; // Bridge instance, will be set if the element was requested externally. @@ -561,6 +562,7 @@ export class Renderer implements IRenderer { private currentNode: VirtualNode | undefined; private nodeStack: VirtualNode[] = []; + private rootElementEndingRender: RenderedElement | undefined; readonly nodeTree: VirtualNode; private readonly elementTree: RenderedElement; @@ -978,6 +980,9 @@ export class Renderer implements IRenderer { } this.pushVirtualNode(resolvedNode); + if (resolvedNode.parentElement === this.elementTree) { + resolvedNode.element!.directionWasSetDuringRender = false; + } if (justCreated && nodePrototype.attributes) { enumeratePropertyList(nodePrototype.attributes, (name, value) => { @@ -989,10 +994,15 @@ export class Renderer implements IRenderer { endElement() { const currentNode = this.currentNode; if (currentNode && currentNode.parentElement === this.elementTree) { - for (const observer of this.observers) { - if (observer.onRootElementWillEndRender) { - observer.onRootElementWillEndRender(); + this.rootElementEndingRender = currentNode.element; + try { + for (const observer of this.observers) { + if (observer.onRootElementWillEndRender) { + observer.onRootElementWillEndRender(); + } } + } finally { + this.rootElementEndingRender = undefined; } } @@ -1459,13 +1469,7 @@ export class Renderer implements IRenderer { // We need to render it with a new key const duplicateKeyIndex = children.insertionIndex - resolvedNode.parentIndex + 1; - return this.resolveVirtualNode( - parent, - key, - duplicateKeyIndex, - componentConstructor, - componentPrototype, - ); + return this.resolveVirtualNode(parent, key, duplicateKeyIndex, componentConstructor, componentPrototype); } } @@ -1742,6 +1746,7 @@ export class Renderer implements IRenderer { setAttributeString(name: string, value: string | undefined): boolean { const element = this.getCurrentElement(); + this.recordElementDirectionFromCurrentRender(element, name); const attributes = element.attributes; if (attributes[name] === value) { return false; @@ -1838,6 +1843,7 @@ export class Renderer implements IRenderer { } setAttributeOnElement(element: RenderedElement, name: string, value: any): boolean { + this.recordElementDirectionFromCurrentRender(element, name); if (typeof value !== 'string' && (name === 'class' || name === '$class')) { value = classNames(value); } @@ -2570,14 +2576,29 @@ export class Renderer implements IRenderer { return nodes; } - getRootElement(): IRenderedElement | undefined { + /** Return every top-level rendered element in render order. */ + getRootElements(): IRenderedElement[] { const out: IRenderedElement[] = []; if (this.nodeTree.children) { for (const node of this.nodeTree.children.children) { this.collectElements(node, out); } } - return out[0]; + return out; + } + + /** Reports a declarative direction write for the root currently notifying render observers. */ + wasElementDirectionSetDuringCurrentRender(element: IRenderedElement): boolean { + const renderedElement = this.getElementById(element.id); + return ( + renderedElement !== undefined && + renderedElement === this.rootElementEndingRender && + renderedElement.directionWasSetDuringRender === true + ); + } + + getRootElement(): IRenderedElement | undefined { + return this.getRootElements()[0]; } getComponentKey(component: IComponent): string { @@ -2746,6 +2767,17 @@ export class Renderer implements IRenderer { return this.elementById[elementId]; } + private recordElementDirectionFromCurrentRender(element: RenderedElement, attributeName: string): void { + if ( + attributeName !== 'direction' || + this.rootElementEndingRender !== undefined || + this.currentNode?.element !== element + ) { + return; + } + element.directionWasSetDuringRender = true; + } + private processFrameUpdates(updates: Float64Array) { const elementById = this.elementById; let elementsWithCallback: RenderedElement[] | undefined; diff --git a/src/valdi_modules/src/valdi/valdi_core/src/RootComponentsManager.ts b/src/valdi_modules/src/valdi/valdi_core/src/RootComponentsManager.ts index e4a3da2e..fc2ef8cd 100644 --- a/src/valdi_modules/src/valdi/valdi_core/src/RootComponentsManager.ts +++ b/src/valdi_modules/src/valdi/valdi_core/src/RootComponentsManager.ts @@ -21,6 +21,7 @@ import { } from './debugging/DaemonClientManager'; import { DebugLevel, SubmitDebugMessageFunc } from './debugging/DebugMessage'; import { DaemonClientMessageType, Messages, RemoteValdiContext } from './debugging/Messages'; +import { NativeAppearanceDebugSettings } from './debugging/NativeAppearanceDebugSettings'; import { trace } from './utils/Trace'; interface StashedRootComponentHandle { @@ -51,6 +52,7 @@ export class RootComponentsManager implements IRootComponentsManager, IDaemonCli readonly rootComponents: StringMap = {}; private reloadedContextIds?: string[]; + private nativeAppearanceDebugSettings?: NativeAppearanceDebugSettings; constructor( readonly rendererFactory: RendererFactory, @@ -59,6 +61,7 @@ export class RootComponentsManager implements IRootComponentsManager, IDaemonCli ) { if (daemonClientManager) { daemonClientManager.addListener(this); + this.nativeAppearanceDebugSettings = new NativeAppearanceDebugSettings(); } } @@ -89,6 +92,8 @@ export class RootComponentsManager implements IRootComponentsManager, IDaemonCli if (this.daemonClientManager) { this.daemonClientManager.removeListener(this); } + this.nativeAppearanceDebugSettings?.dispose(); + this.nativeAppearanceDebugSettings = undefined; return handles; } @@ -130,6 +135,7 @@ export class RootComponentsManager implements IRootComponentsManager, IDaemonCli componentContext: any, ): RootComponentHandle { const renderer = this.rendererFactory.makeRenderer(contextId); + const removeAppearanceObserver = this.nativeAppearanceDebugSettings?.addRenderer(renderer); const observerDisposer = registerLogMetadataProvider('Valdi Runtime', renderer.dumpLogMetadata.bind(renderer)); @@ -137,6 +143,7 @@ export class RootComponentsManager implements IRootComponentsManager, IDaemonCli if (onHotReloadSubscription) { onHotReloadSubscription(); } + removeAppearanceObserver?.(); observerDisposer(); renderer.delegate.onDestroyed(); }; diff --git a/src/valdi_modules/src/valdi/valdi_core/src/debugging/DebugSettings.ts b/src/valdi_modules/src/valdi/valdi_core/src/debugging/DebugSettings.ts new file mode 100644 index 00000000..dbd342ec --- /dev/null +++ b/src/valdi_modules/src/valdi/valdi_core/src/debugging/DebugSettings.ts @@ -0,0 +1,426 @@ +import { jsx } from '../JSXBootstrap'; +import type { ValdiRuntime } from '../ValdiRuntime'; +import type { CustomMessageHandler } from './CustomMessageHandler'; + +declare const runtime: ValdiRuntime; + +const DEBUG_SETTINGS_IDENTIFIER = 'ValdiDebuggerSettings'; +const DEBUG_SETTINGS_CONTRACT_VERSION = 1; + +/** + * This module owns only the runtime registry. Debugger presentation is supplied by the stacked settings provider, + * while native delivery uses the generic custom-message transport from the debugger input/core layer. + */ + +/** Debugger clients exchange only primitive setting values across process boundaries. */ +export enum DebugSettingKind { + Toggle = 'toggle', + Select = 'select', + Text = 'text', + Number = 'number', +} + +export type DebugSettingValue = boolean | number | string; + +export interface DebugSettingOption { + readonly label: string; + readonly value: DebugSettingValue; +} + +interface DebugSettingBase { + readonly defaultValue: Value; + readonly description?: string; + readonly id: string; + readonly kind: Kind; + readonly label: string; + readonly get: () => Value; + readonly set: (value: Value) => void; +} + +export type ToggleDebugSetting = DebugSettingBase; + +export type SelectDebugSetting = DebugSettingBase & { + readonly options: readonly DebugSettingOption[]; +}; + +export type TextDebugSetting = DebugSettingBase; + +export type NumberDebugSetting = DebugSettingBase; + +export type DebugSetting = ToggleDebugSetting | SelectDebugSetting | TextDebugSetting | NumberDebugSetting; + +export interface DebugSettingsGroup { + /** Stable identity used to combine settings from independent owners into one debugger category. */ + readonly categoryId: string; + readonly id: string; + readonly label: string; + readonly settings: readonly DebugSetting[]; +} + +export interface DebugSettingsRegistration { + dispose(): void; + notifyChange(): void; +} + +export interface DebugSettingSnapshot { + readonly defaultValue: DebugSettingValue; + readonly description?: string; + readonly id: string; + readonly kind: DebugSettingKind; + readonly label: string; + readonly options?: readonly DebugSettingOption[]; + readonly value: DebugSettingValue; +} + +export interface DebugSettingsGroupSnapshot { + readonly categoryId: string; + readonly id: string; + readonly label: string; + readonly settings: readonly DebugSettingSnapshot[]; +} + +export interface DebugSettingsSnapshot { + readonly contractVersion: number; + readonly groups: readonly DebugSettingsGroupSnapshot[]; + readonly revision: number; +} + +interface DebugSettingsRequest { + readonly action?: string; + readonly groupId?: string; + readonly settingId?: string; + readonly value?: unknown; +} + +interface DebugSettingsGlobal { + __VALDI_DEBUG_SETTINGS__?: DebugSettingsBridge; +} + +interface DebugSettingsBridge { + getSnapshot(): DebugSettingsSnapshot; + resetValue(groupId: string, settingId: string): DebugSettingsSnapshot; + setValue(groupId: string, settingId: string, value: unknown): DebugSettingsSnapshot; +} + +interface RegisteredDebugSettingsGroup { + readonly categoryId: string; + readonly group: DebugSettingsGroup; + readonly id: string; + readonly label: string; + readonly sequence: number; +} + +const registeredGroups = new Map(); +let registryRevision = 0; +let registrationSequence = 0; +let registeredHandler: CustomMessageHandler | undefined; + +const inactiveRegistration: DebugSettingsRegistration = { + dispose(): void {}, + notifyChange(): void {}, +}; + +function activeRegistrations(): RegisteredDebugSettingsGroup[] { + const active: RegisteredDebugSettingsGroup[] = []; + registeredGroups.forEach(groupRegistrations => { + const registration = groupRegistrations[groupRegistrations.length - 1]; + if (registration !== undefined) { + active.push(registration); + } + }); + active.sort((left, right) => right.sequence - left.sequence); + return active; +} + +function snapshotSetting(setting: DebugSetting, value: DebugSettingValue): DebugSettingSnapshot { + return { + defaultValue: setting.defaultValue, + ...(setting.description === undefined ? {} : { description: setting.description }), + id: setting.id, + kind: setting.kind, + label: setting.label, + ...(setting.kind === DebugSettingKind.Select + ? { options: setting.options.map(option => ({ label: option.label, value: option.value })) } + : {}), + value, + }; +} + +function debugSettingsSnapshot(): DebugSettingsSnapshot { + const active = activeRegistrations(); + const groupsByCategory = new Map(); + active.forEach(registration => { + const categoryGroups = groupsByCategory.get(registration.categoryId) ?? []; + categoryGroups.push(registration); + groupsByCategory.set(registration.categoryId, categoryGroups); + }); + + const groups: DebugSettingsGroupSnapshot[] = []; + groupsByCategory.forEach(categoryGroups => { + const primaryRegistration = categoryGroups[0]; + if (primaryRegistration === undefined) { + return; + } + const settingIds = new Set(); + const settings: DebugSettingSnapshot[] = []; + categoryGroups.forEach(registration => { + registration.group.settings.forEach(setting => { + if (settingIds.has(setting.id)) { + return; + } + settingIds.add(setting.id); + validateSettingDeclaration(setting); + settings.push(snapshotSetting(setting, validateSettingValue(setting, setting.get()))); + }); + }); + groups.push({ + categoryId: primaryRegistration.categoryId, + id: primaryRegistration.id, + label: primaryRegistration.label, + settings, + }); + }); + return { contractVersion: DEBUG_SETTINGS_CONTRACT_VERSION, groups, revision: registryRevision }; +} + +function activeSetting(groupId: string, settingId: string): DebugSetting { + const registrations = registeredGroups.get(groupId); + const registration = registrations?.[registrations.length - 1]; + if (registration === undefined) { + throw new Error(`Unknown debug settings group: ${groupId}`); + } + const categoryId = registration.categoryId; + for (const candidateRegistration of activeRegistrations()) { + if (candidateRegistration.categoryId === categoryId) { + const setting = candidateRegistration.group.settings.find(candidate => candidate.id === settingId); + if (setting !== undefined) { + return setting; + } + } + } + throw new Error(`Unknown debug setting: ${groupId}.${settingId}`); +} + +function validateWireValue(settingId: string, value: unknown): DebugSettingValue { + if (typeof value === 'number' && !Number.isFinite(value)) { + throw new TypeError(`Debug setting ${settingId} requires a finite number`); + } + if (typeof value !== 'boolean' && typeof value !== 'number' && typeof value !== 'string') { + throw new TypeError(`Debug setting ${settingId} requires a primitive value`); + } + return value; +} + +function validateSettingValue(setting: DebugSetting, value: unknown): DebugSettingValue { + const settingId = setting.id; + const wireValue = validateWireValue(setting.id, value); + switch (setting.kind) { + case DebugSettingKind.Toggle: + if (typeof wireValue !== 'boolean') { + throw new TypeError(`Debug setting ${setting.id} requires a boolean value`); + } + break; + case DebugSettingKind.Select: + if (!setting.options.some(option => option.value === wireValue)) { + throw new TypeError(`Debug setting ${setting.id} requires one of its declared options`); + } + break; + case DebugSettingKind.Text: + if (typeof wireValue !== 'string') { + throw new TypeError(`Debug setting ${setting.id} requires a string value`); + } + break; + case DebugSettingKind.Number: + if (typeof wireValue !== 'number') { + throw new TypeError(`Debug setting ${setting.id} requires a finite number`); + } + break; + default: + throw new TypeError(`Debug setting ${settingId} has an unsupported kind`); + } + return wireValue; +} + +function validateSettingDeclaration(setting: DebugSetting): void { + if ( + typeof setting.id !== 'string' || + setting.id.length === 0 || + typeof setting.label !== 'string' || + setting.label.length === 0 + ) { + throw new Error('Debug settings require a non-empty id and label'); + } + const settingId = setting.id; + if (setting.description !== undefined && typeof setting.description !== 'string') { + throw new TypeError(`Debug setting ${settingId} requires a string description`); + } + + const declaredOptions = (setting as { readonly options?: unknown }).options; + switch (setting.kind) { + case DebugSettingKind.Select: { + if (!Array.isArray(setting.options) || setting.options.length === 0) { + throw new Error(`Debug setting ${setting.id} requires at least one declared option`); + } + const optionValues = new Set(); + setting.options.forEach(option => { + if (typeof option.label !== 'string' || option.label.length === 0) { + throw new Error(`Debug setting ${setting.id} contains an option without a label`); + } + const optionValue = validateWireValue(setting.id, option.value); + if (optionValues.has(optionValue)) { + throw new Error(`Debug setting ${setting.id} contains a duplicate option value`); + } + optionValues.add(optionValue); + }); + break; + } + case DebugSettingKind.Toggle: + case DebugSettingKind.Text: + case DebugSettingKind.Number: + if (declaredOptions !== undefined) { + throw new Error(`Debug setting ${setting.id} declares options but is not a select setting`); + } + break; + default: + throw new Error(`Debug setting ${settingId} has an unsupported kind`); + } + validateSettingValue(setting, setting.defaultValue); +} + +function changeSetting(groupId: string, settingId: string, value: unknown): DebugSettingsSnapshot { + const setting = activeSetting(groupId, settingId); + const validatedValue = validateSettingValue(setting, value); + switch (setting.kind) { + case DebugSettingKind.Toggle: + setting.set(validatedValue as boolean); + break; + case DebugSettingKind.Select: + setting.set(validatedValue); + break; + case DebugSettingKind.Text: + setting.set(validatedValue as string); + break; + case DebugSettingKind.Number: + setting.set(validatedValue as number); + break; + } + registryRevision++; + return debugSettingsSnapshot(); +} + +function handleRequest(request: DebugSettingsRequest): DebugSettingsSnapshot { + const action = request.action ?? 'list'; + if (action === 'list') { + return debugSettingsSnapshot(); + } + if (typeof request.groupId !== 'string' || typeof request.settingId !== 'string') { + throw new Error('Debug setting changes require groupId and settingId'); + } + if (action === 'set') { + return changeSetting(request.groupId, request.settingId, request.value); + } + if (action === 'reset') { + const setting = activeSetting(request.groupId, request.settingId); + return changeSetting(request.groupId, request.settingId, setting.defaultValue); + } + throw new Error(`Unsupported debug settings action: ${action}`); +} + +function installRegistryBridge(): void { + if (registeredHandler !== undefined) { + return; + } + const handler: CustomMessageHandler = { + messageReceived(identifier: string, data: unknown): Promise | undefined { + if (identifier !== DEBUG_SETTINGS_IDENTIFIER) { + return undefined; + } + const request = typeof data === 'object' && data !== null ? (data as DebugSettingsRequest) : {}; + try { + return Promise.resolve(handleRequest(request)); + } catch (error) { + return Promise.reject(error); + } + }, + }; + jsx.addCustomMessageHandler(handler); + registeredHandler = handler; + const globals = globalThis as unknown as DebugSettingsGlobal; + globals.__VALDI_DEBUG_SETTINGS__ = { + getSnapshot: debugSettingsSnapshot, + resetValue: (groupId, settingId) => handleRequest({ action: 'reset', groupId, settingId }), + setValue: (groupId, settingId, value) => handleRequest({ action: 'set', groupId, settingId, value }), + }; +} + +function removeRegistryBridgeIfUnused(): void { + if (registeredGroups.size !== 0 || registeredHandler === undefined) { + return; + } + jsx.removeCustomMessageHandler(registeredHandler); + registeredHandler = undefined; + delete (globalThis as unknown as DebugSettingsGlobal).__VALDI_DEBUG_SETTINGS__; +} + +/** Publish runtime or application tuning controls to attached debug runtimes only. */ +export function registerDebugSettingsGroup(group: DebugSettingsGroup): DebugSettingsRegistration { + if (!runtime.isDebugEnabled) { + return inactiveRegistration; + } + if ( + typeof group.categoryId !== 'string' || + group.categoryId.length === 0 || + typeof group.id !== 'string' || + group.id.length === 0 || + typeof group.label !== 'string' || + group.label.length === 0 + ) { + throw new Error('Debug settings groups require a non-empty categoryId, id, and label'); + } + const ids = new Set(); + group.settings.forEach(setting => { + if (ids.has(setting.id)) { + throw new Error(`Debug settings group ${group.id} contains an invalid or duplicate setting id`); + } + ids.add(setting.id); + validateSettingDeclaration(setting); + }); + + installRegistryBridge(); + const registrations = registeredGroups.get(group.id) ?? []; + const registration: RegisteredDebugSettingsGroup = { + categoryId: group.categoryId, + group, + id: group.id, + label: group.label, + sequence: ++registrationSequence, + }; + registrations.push(registration); + registeredGroups.set(group.id, registrations); + registryRevision++; + + let disposed = false; + return { + dispose(): void { + if (disposed) { + return; + } + disposed = true; + const current = registeredGroups.get(registration.id); + const index = current?.indexOf(registration); + if (current !== undefined && index !== undefined && index >= 0) { + current.splice(index, 1); + if (current.length === 0) { + registeredGroups.delete(registration.id); + } + registryRevision++; + } + removeRegistryBridgeIfUnused(); + }, + notifyChange(): void { + if (!disposed) { + registryRevision++; + } + }, + }; +} diff --git a/src/valdi_modules/src/valdi/valdi_core/src/debugging/NativeAppearanceDebugSettings.ts b/src/valdi_modules/src/valdi/valdi_core/src/debugging/NativeAppearanceDebugSettings.ts new file mode 100644 index 00000000..f82ef71c --- /dev/null +++ b/src/valdi_modules/src/valdi/valdi_core/src/debugging/NativeAppearanceDebugSettings.ts @@ -0,0 +1,175 @@ +import type { IRenderedElement } from '../IRenderedElement'; +import type { RendererObserver } from '../IRenderer'; +import type { Renderer } from '../Renderer'; +import { DebugSettingKind, registerDebugSettingsGroup } from './DebugSettings'; +import type { DebugSettingsRegistration } from './DebugSettings'; + +export enum NativeLayoutDirection { + System = 'system', + LeftToRight = 'ltr', + RightToLeft = 'rtl', +} + +interface RootDirectionState { + appliedDirection: NativeLayoutDirection.LeftToRight | NativeLayoutDirection.RightToLeft; + originalDirection: unknown; +} + +interface RendererRegistration { + readonly observer: RendererObserver; + readonly rootDirections: Map; + renderCompletionScheduled: boolean; +} + +export class NativeAppearanceDebugSettings { + private readonly renderers = new Map(); + private readonly registration: DebugSettingsRegistration; + private layoutDirection = NativeLayoutDirection.System; + + constructor() { + this.registration = registerDebugSettingsGroup({ + categoryId: 'appearance', + id: 'valdi.appearance', + label: 'Appearance', + settings: [ + { + defaultValue: NativeLayoutDirection.System, + description: 'Overrides layout direction for every mounted Valdi renderer.', + get: () => this.layoutDirection, + id: 'layout-direction', + kind: DebugSettingKind.Select, + label: 'Layout direction', + options: [ + { label: 'System', value: NativeLayoutDirection.System }, + { label: 'Left to right', value: NativeLayoutDirection.LeftToRight }, + { label: 'Right to left', value: NativeLayoutDirection.RightToLeft }, + ], + set: value => this.setLayoutDirection(value as NativeLayoutDirection), + }, + ], + }); + } + + addRenderer(renderer: Renderer): () => void { + const previousRegistration = this.renderers.get(renderer); + if (previousRegistration !== undefined) { + renderer.removeObserver(previousRegistration.observer); + } + + const observer: RendererObserver = { + onRootElementWillEndRender: () => { + this.applyLayoutDirection(renderer); + this.schedulePostRenderApply(renderer); + }, + }; + const rendererRegistration: RendererRegistration = { + observer, + renderCompletionScheduled: false, + rootDirections: previousRegistration?.rootDirections ?? new Map(), + }; + this.renderers.set(renderer, rendererRegistration); + renderer.addObserver(observer); + this.applyLayoutDirection(renderer); + + return () => { + if (this.renderers.get(renderer)?.observer === observer) { + this.restoreLayoutDirections(renderer, rendererRegistration); + this.renderers.delete(renderer); + renderer.removeObserver(observer); + } + }; + } + + dispose(): void { + this.registration.dispose(); + this.renderers.forEach((rendererRegistration, renderer) => { + this.restoreLayoutDirections(renderer, rendererRegistration); + renderer.removeObserver(rendererRegistration.observer); + }); + this.renderers.clear(); + } + + private setLayoutDirection(layoutDirection: NativeLayoutDirection): void { + this.layoutDirection = layoutDirection; + this.renderers.forEach((_registration, renderer) => { + this.applyLayoutDirection(renderer); + }); + } + + private schedulePostRenderApply(renderer: Renderer): void { + const registration = this.renderers.get(renderer); + if ( + this.layoutDirection === NativeLayoutDirection.System || + registration === undefined || + registration.renderCompletionScheduled + ) { + return; + } + registration.renderCompletionScheduled = true; + renderer.onRenderComplete(() => { + if (this.renderers.get(renderer) !== registration) { + return; + } + registration.renderCompletionScheduled = false; + this.applyLayoutDirection(renderer); + }); + } + + private applyLayoutDirection(renderer: Renderer): void { + const registration = this.renderers.get(renderer); + if (registration === undefined) { + return; + } + const roots = renderer.getRootElements(); + this.pruneStaleRootDirections(roots, registration); + const layoutDirection = this.layoutDirection; + if (layoutDirection === NativeLayoutDirection.System) { + this.restoreCurrentLayoutDirections(roots, registration); + return; + } + + roots.forEach(root => { + const currentDirection = root.getAttribute('direction'); + const rootDirection = registration.rootDirections.get(root); + if (rootDirection === undefined) { + registration.rootDirections.set(root, { + appliedDirection: layoutDirection, + originalDirection: currentDirection, + }); + } else { + if (renderer.wasElementDirectionSetDuringCurrentRender(root)) { + rootDirection.originalDirection = currentDirection; + } + rootDirection.appliedDirection = layoutDirection; + } + root.setAttribute('direction', layoutDirection); + }); + } + + private restoreLayoutDirections(renderer: Renderer, registration: RendererRegistration): void { + const roots = renderer.getRootElements(); + this.pruneStaleRootDirections(roots, registration); + this.restoreCurrentLayoutDirections(roots, registration); + } + + private pruneStaleRootDirections(roots: readonly IRenderedElement[], registration: RendererRegistration): void { + const currentRoots = new Set(roots); + registration.rootDirections.forEach((_rootDirection, root) => { + if (currentRoots.has(root)) { + return; + } + // Detached or destroyed roots are no longer safe mutation targets. + registration.rootDirections.delete(root); + }); + } + + private restoreCurrentLayoutDirections(roots: readonly IRenderedElement[], registration: RendererRegistration): void { + roots.forEach(root => { + const rootDirection = registration.rootDirections.get(root); + if (rootDirection !== undefined) { + root.setAttribute('direction', rootDirection.originalDirection); + registration.rootDirections.delete(root); + } + }); + } +} diff --git a/src/valdi_modules/src/valdi/valdi_core/test/DebugSettings.spec.ts b/src/valdi_modules/src/valdi/valdi_core/test/DebugSettings.spec.ts new file mode 100644 index 00000000..1c1e3fb2 --- /dev/null +++ b/src/valdi_modules/src/valdi/valdi_core/test/DebugSettings.spec.ts @@ -0,0 +1,639 @@ +import 'jasmine/src/jasmine'; +import { jsx } from '../src/JSXBootstrap'; +import type { ValdiRuntime } from '../src/ValdiRuntime'; +import { DebugSettingKind, registerDebugSettingsGroup } from '../src/debugging/DebugSettings'; +import type { CustomMessageHandler } from '../src/debugging/CustomMessageHandler'; +import type { + DebugSettingsGroup, + DebugSettingsRegistration, + DebugSettingsSnapshot, +} from '../src/debugging/DebugSettings'; + +declare const runtime: ValdiRuntime; + +interface DebugSettingsTestGlobal { + __VALDI_DEBUG_SETTINGS__?: { + getSnapshot(): DebugSettingsSnapshot; + resetValue(groupId: string, settingId: string): DebugSettingsSnapshot; + setValue(groupId: string, settingId: string, value: unknown): DebugSettingsSnapshot; + }; +} + +describe('DebugSettings', () => { + let originalDebugEnabled: boolean; + let registrations: DebugSettingsRegistration[]; + + beforeEach(() => { + originalDebugEnabled = runtime.isDebugEnabled; + runtime.isDebugEnabled = true; + registrations = []; + }); + + afterEach(() => { + registrations.forEach(registration => registration.dispose()); + registrations = []; + runtime.isDebugEnabled = originalDebugEnabled; + }); + + it('publishes typed groups and applies changes through the application setter', () => { + let mode = 'system'; + registrations.push( + registerDebugSettingsGroup({ + categoryId: 'appearance', + id: 'appearance', + label: 'Appearance', + settings: [ + { + defaultValue: 'system', + get: () => mode, + id: 'theme', + kind: DebugSettingKind.Select, + label: 'Theme', + options: [ + { label: 'System', value: 'system' }, + { label: 'Dark', value: 'dark' }, + ], + set: value => { + mode = value as string; + }, + }, + ], + }), + ); + + const bridge = (globalThis as unknown as DebugSettingsTestGlobal).__VALDI_DEBUG_SETTINGS__; + expect(bridge?.getSnapshot().groups).toEqual([ + jasmine.objectContaining({ + id: 'appearance', + settings: [jasmine.objectContaining({ id: 'theme', kind: DebugSettingKind.Select, value: 'system' })], + }), + ]); + expect(bridge?.setValue('appearance', 'theme', 'dark').groups[0]?.settings[0]?.value).toBe('dark'); + expect(mode).toBe('dark'); + expect(bridge?.resetValue('appearance', 'theme').groups[0]?.settings[0]?.value).toBe('system'); + expect(mode).toBe('system'); + }); + + it('rejects invalid values and restores the previous registration on disposal', () => { + let enabled = false; + const first = registerDebugSettingsGroup({ + categoryId: 'experiments', + id: 'experiments', + label: 'Original experiments', + settings: [ + { + defaultValue: false, + get: () => enabled, + id: 'feature', + kind: DebugSettingKind.Toggle, + label: 'Feature', + set: value => { + enabled = value as boolean; + }, + }, + ], + }); + registrations.push(first); + const second = registerDebugSettingsGroup({ + categoryId: 'experiments', + id: 'experiments', + label: 'Replacement experiments', + settings: [], + }); + registrations.push(second); + + const bridge = (globalThis as unknown as DebugSettingsTestGlobal).__VALDI_DEBUG_SETTINGS__; + expect(bridge?.getSnapshot().groups[0]?.label).toBe('Replacement experiments'); + second.dispose(); + expect(bridge?.getSnapshot().groups[0]?.label).toBe('Original experiments'); + expect(() => bridge?.setValue('experiments', 'feature', 'true')).toThrowError( + 'Debug setting feature requires a boolean value', + ); + expect(enabled).toBeFalse(); + }); + + it('combines matching framework and application categories without changing setting ownership', () => { + let rightToLeft = false; + let theme = 'system'; + const framework = registerDebugSettingsGroup({ + categoryId: 'appearance', + id: 'valdi.appearance', + label: 'Framework appearance', + settings: [ + { + defaultValue: false, + get: () => rightToLeft, + id: 'rtl', + kind: DebugSettingKind.Toggle, + label: 'Right-to-left layout', + set: value => { + rightToLeft = value as boolean; + }, + }, + ], + }); + registrations.push(framework); + const application = registerDebugSettingsGroup({ + categoryId: 'appearance', + id: 'app.appearance', + label: 'Appearance', + settings: [ + { + defaultValue: 'system', + get: () => theme, + id: 'theme', + kind: DebugSettingKind.Select, + label: 'Theme', + options: [ + { label: 'System', value: 'system' }, + { label: 'Dark', value: 'dark' }, + ], + set: value => { + theme = value as string; + }, + }, + ], + }); + registrations.push(application); + + const bridge = (globalThis as unknown as DebugSettingsTestGlobal).__VALDI_DEBUG_SETTINGS__; + expect(bridge?.getSnapshot().groups).toEqual([ + jasmine.objectContaining({ + id: 'app.appearance', + label: 'Appearance', + settings: [ + jasmine.objectContaining({ id: 'theme', value: 'system' }), + jasmine.objectContaining({ id: 'rtl', value: false }), + ], + }), + ]); + + bridge?.setValue('app.appearance', 'rtl', true); + bridge?.setValue('valdi.appearance', 'theme', 'dark'); + expect(rightToLeft).toBeTrue(); + expect(theme).toBe('dark'); + + application.dispose(); + expect(bridge?.getSnapshot().groups).toEqual([ + jasmine.objectContaining({ + id: 'valdi.appearance', + settings: [jasmine.objectContaining({ id: 'rtl', value: true })], + }), + ]); + }); + + it('uses registration sequence for deterministic A/B/A2 category precedence', () => { + let firstAValue = false; + let bValue = false; + let secondAValue = false; + const firstA = registerDebugSettingsGroup({ + categoryId: 'shared', + id: 'owner-a', + label: 'A', + settings: [ + { + defaultValue: false, + get: () => firstAValue, + id: 'enabled', + kind: DebugSettingKind.Toggle, + label: 'A enabled', + set: value => { + firstAValue = value; + }, + }, + ], + }); + registrations.push(firstA); + registrations.push( + registerDebugSettingsGroup({ + categoryId: 'shared', + id: 'owner-b', + label: 'B', + settings: [ + { + defaultValue: false, + get: () => bValue, + id: 'enabled', + kind: DebugSettingKind.Toggle, + label: 'B enabled', + set: value => { + bValue = value; + }, + }, + ], + }), + ); + const secondA = registerDebugSettingsGroup({ + categoryId: 'shared', + id: 'owner-a', + label: 'A2', + settings: [ + { + defaultValue: false, + get: () => secondAValue, + id: 'enabled', + kind: DebugSettingKind.Toggle, + label: 'A2 enabled', + set: value => { + secondAValue = value; + }, + }, + ], + }); + registrations.push(secondA); + + const bridge = (globalThis as unknown as DebugSettingsTestGlobal).__VALDI_DEBUG_SETTINGS__; + expect(bridge?.getSnapshot().groups).toEqual([ + jasmine.objectContaining({ + categoryId: 'shared', + id: 'owner-a', + label: 'A2', + settings: [jasmine.objectContaining({ id: 'enabled', label: 'A2 enabled' })], + }), + ]); + bridge?.setValue('owner-b', 'enabled', true); + expect(secondAValue).toBeTrue(); + expect(bValue).toBeFalse(); + expect(firstAValue).toBeFalse(); + + secondA.dispose(); + expect(bridge?.getSnapshot().groups).toEqual([ + jasmine.objectContaining({ + id: 'owner-b', + label: 'B', + settings: [jasmine.objectContaining({ id: 'enabled', label: 'B enabled' })], + }), + ]); + bridge?.setValue('owner-a', 'enabled', true); + expect(bValue).toBeTrue(); + expect(firstAValue).toBeFalse(); + }); + + it('does not merge unrelated categories that share a display label', () => { + registrations.push( + registerDebugSettingsGroup({ categoryId: 'first', id: 'first', label: 'Shared label', settings: [] }), + ); + registrations.push( + registerDebugSettingsGroup({ categoryId: 'second', id: 'second', label: 'Shared label', settings: [] }), + ); + + const bridge = (globalThis as unknown as DebugSettingsTestGlobal).__VALDI_DEBUG_SETTINGS__; + expect(bridge?.getSnapshot().groups).toEqual([ + jasmine.objectContaining({ categoryId: 'second', id: 'second' }), + jasmine.objectContaining({ categoryId: 'first', id: 'first' }), + ]); + }); + + it('supports freeform text and finite numeric controls', () => { + let variant = ''; + let sampleRate = 1; + registrations.push( + registerDebugSettingsGroup({ + categoryId: 'advanced', + id: 'advanced', + label: 'Advanced', + settings: [ + { + defaultValue: '', + get: () => variant, + id: 'variant', + kind: DebugSettingKind.Text, + label: 'Experiment variant', + set: value => { + variant = value as string; + }, + }, + { + defaultValue: 1, + get: () => sampleRate, + id: 'sample-rate', + kind: DebugSettingKind.Number, + label: 'Sample rate', + set: value => { + sampleRate = value as number; + }, + }, + ], + }), + ); + + const bridge = (globalThis as unknown as DebugSettingsTestGlobal).__VALDI_DEBUG_SETTINGS__; + bridge?.setValue('advanced', 'variant', 'treatment'); + bridge?.setValue('advanced', 'sample-rate', 0.25); + + expect(variant).toBe('treatment'); + expect(sampleRate).toBe(0.25); + expect(() => bridge?.setValue('advanced', 'variant', true)).toThrowError( + 'Debug setting variant requires a string value', + ); + expect(() => bridge?.setValue('advanced', 'sample-rate', Number.NaN)).toThrowError( + 'Debug setting sample-rate requires a finite number', + ); + }); + + it('validates select options and defaults before publishing a group', () => { + expect(() => + registerDebugSettingsGroup({ + categoryId: 'invalid', + id: 'empty-options', + label: 'Invalid', + settings: [ + { + defaultValue: 'missing', + get: () => 'missing', + id: 'choice', + kind: DebugSettingKind.Select, + label: 'Choice', + options: [], + set: () => {}, + }, + ], + }), + ).toThrowError('Debug setting choice requires at least one declared option'); + + expect(() => + registerDebugSettingsGroup({ + categoryId: 'invalid', + id: 'non-finite-option', + label: 'Invalid', + settings: [ + { + defaultValue: Number.POSITIVE_INFINITY, + get: () => Number.POSITIVE_INFINITY, + id: 'choice', + kind: DebugSettingKind.Select, + label: 'Choice', + options: [{ label: 'Infinity', value: Number.POSITIVE_INFINITY }], + set: () => {}, + }, + ], + }), + ).toThrowError('Debug setting choice requires a finite number'); + + expect(() => + registerDebugSettingsGroup({ + categoryId: 'invalid', + id: 'invalid-default', + label: 'Invalid', + settings: [ + { + defaultValue: 'missing', + get: () => 'missing', + id: 'choice', + kind: DebugSettingKind.Select, + label: 'Choice', + options: [{ label: 'Available', value: 'available' }], + set: () => {}, + }, + ], + }), + ).toThrowError('Debug setting choice requires one of its declared options'); + + expect(() => + registerDebugSettingsGroup({ + categoryId: 'invalid', + id: 'duplicate-options', + label: 'Invalid', + settings: [ + { + defaultValue: 'same', + get: () => 'same', + id: 'choice', + kind: DebugSettingKind.Select, + label: 'Choice', + options: [ + { label: 'First', value: 'same' }, + { label: 'Second', value: 'same' }, + ], + set: () => {}, + }, + ], + }), + ).toThrowError('Debug setting choice contains a duplicate option value'); + + const toggleWithOptions = { + categoryId: 'invalid', + id: 'toggle-options', + label: 'Invalid', + settings: [ + { + defaultValue: false, + get: () => false, + id: 'toggle', + kind: DebugSettingKind.Toggle, + label: 'Toggle', + options: [{ label: 'Invalid', value: true }], + set: () => {}, + }, + ], + } as unknown as DebugSettingsGroup; + expect(() => registerDebugSettingsGroup(toggleWithOptions)).toThrowError( + 'Debug setting toggle declares options but is not a select setting', + ); + }); + + it('validates visible getter values after resolving setting precedence', () => { + let selected = 1; + registrations.push( + registerDebugSettingsGroup({ + categoryId: 'runtime-validation', + id: 'runtime-validation', + label: 'Runtime validation', + settings: [ + { + defaultValue: 1, + get: () => selected, + id: 'choice', + kind: DebugSettingKind.Select, + label: 'Choice', + options: [ + { label: 'One', value: 1 }, + { label: 'Two', value: 2 }, + ], + set: value => { + selected = value as number; + }, + }, + ], + }), + ); + const bridge = (globalThis as unknown as DebugSettingsTestGlobal).__VALDI_DEBUG_SETTINGS__; + + expect(() => bridge?.setValue('runtime-validation', 'choice', Number.NEGATIVE_INFINITY)).toThrowError( + 'Debug setting choice requires a finite number', + ); + const override = registerDebugSettingsGroup({ + categoryId: 'runtime-validation', + id: 'runtime-validation-override', + label: 'Runtime validation override', + settings: [ + { + defaultValue: 2, + get: () => 2, + id: 'choice', + kind: DebugSettingKind.Select, + label: 'Replacement choice', + options: [{ label: 'Two', value: 2 }], + set: () => {}, + }, + ], + }); + registrations.push(override); + selected = Number.NaN; + + expect(bridge?.getSnapshot().groups[0]?.settings[0]?.value).toBe(2); + override.dispose(); + expect(() => bridge?.getSnapshot()).toThrowError('Debug setting choice requires a finite number'); + }); + + it('produces a JSON-safe snapshot without functions or non-finite values', () => { + let enabled = true; + registrations.push( + registerDebugSettingsGroup({ + categoryId: 'serialization', + id: 'serialization', + label: 'Serialization', + settings: [ + { + defaultValue: false, + description: 'Serializable setting', + get: () => enabled, + id: 'enabled', + kind: DebugSettingKind.Toggle, + label: 'Enabled', + set: value => { + enabled = value; + }, + }, + ], + }), + ); + const bridge = (globalThis as unknown as DebugSettingsTestGlobal).__VALDI_DEBUG_SETTINGS__; + const snapshot = bridge!.getSnapshot(); + const serialized = JSON.stringify(snapshot); + + expect(JSON.parse(serialized)).toEqual(snapshot); + expect(serialized).toContain('"categoryId":"serialization"'); + expect(serialized).not.toContain('"get":'); + expect(serialized).not.toContain('"set":'); + }); + + it('projects select options to exact JSON-safe wire objects', () => { + const adversarialOption: { + callback: () => void; + cycle?: unknown; + label: string; + toJSON(): never; + value: string; + } = { + callback: () => {}, + label: 'Safe', + toJSON: () => { + throw new Error('Original option object crossed the snapshot boundary'); + }, + value: 'safe', + }; + adversarialOption.cycle = adversarialOption; + const group = { + categoryId: 'option-serialization', + id: 'option-serialization', + label: 'Option serialization', + settings: [ + { + defaultValue: 'safe', + get: () => 'safe', + id: 'choice', + kind: DebugSettingKind.Select, + label: 'Choice', + options: [adversarialOption], + set: () => {}, + }, + ], + } as unknown as DebugSettingsGroup; + registrations.push(registerDebugSettingsGroup(group)); + + const bridge = (globalThis as unknown as DebugSettingsTestGlobal).__VALDI_DEBUG_SETTINGS__; + const snapshot = bridge!.getSnapshot(); + const options = snapshot.groups[0]!.settings[0]!.options!; + + expect(options).toEqual([{ label: 'Safe', value: 'safe' }]); + expect(Object.keys(options[0]!)).toEqual(['label', 'value']); + expect(JSON.parse(JSON.stringify(snapshot))).toEqual(snapshot); + }); + + it('rejects non-string descriptions before publishing a snapshot', () => { + const cyclicDescription: { self?: unknown } = {}; + cyclicDescription.self = cyclicDescription; + const invalidGroup = { + categoryId: 'invalid-description', + id: 'invalid-description', + label: 'Invalid description', + settings: [ + { + defaultValue: false, + description: cyclicDescription, + get: () => false, + id: 'enabled', + kind: DebugSettingKind.Toggle, + label: 'Enabled', + set: () => {}, + }, + ], + } as unknown as DebugSettingsGroup; + + expect(() => registerDebugSettingsGroup(invalidGroup)).toThrowError( + 'Debug setting enabled requires a string description', + ); + }); + + it('answers native daemon custom requests through the same typed registry', async () => { + let enabled = false; + const addHandler = spyOn(jsx, 'addCustomMessageHandler').and.callThrough(); + registrations.push( + registerDebugSettingsGroup({ + categoryId: 'experiments', + id: 'experiments', + label: 'Experiments', + settings: [ + { + defaultValue: false, + get: () => enabled, + id: 'feature', + kind: DebugSettingKind.Toggle, + label: 'Feature', + set: value => { + enabled = value as boolean; + }, + }, + ], + }), + ); + const handler = addHandler.calls.mostRecent().args[0] as CustomMessageHandler; + + expect(handler.messageReceived('UnrelatedMessage', {})).toBeUndefined(); + const snapshot = await handler.messageReceived('ValdiDebuggerSettings', { + action: 'set', + groupId: 'experiments', + settingId: 'feature', + value: true, + }); + + expect(enabled).toBeTrue(); + expect(snapshot).toEqual( + jasmine.objectContaining({ + groups: [jasmine.objectContaining({ id: 'experiments' })], + }), + ); + }); + + it('does not publish runtime controls when debugging is disabled', () => { + runtime.isDebugEnabled = false; + + registrations.push( + registerDebugSettingsGroup({ categoryId: 'hidden', id: 'hidden', label: 'Hidden', settings: [] }), + ); + + const bridge = (globalThis as unknown as DebugSettingsTestGlobal).__VALDI_DEBUG_SETTINGS__; + expect(bridge).toBeUndefined(); + }); +}); diff --git a/src/valdi_modules/src/valdi/valdi_core/test/NativeAppearanceDebugSettings.spec.ts b/src/valdi_modules/src/valdi/valdi_core/test/NativeAppearanceDebugSettings.spec.ts new file mode 100644 index 00000000..3d2d0223 --- /dev/null +++ b/src/valdi_modules/src/valdi/valdi_core/test/NativeAppearanceDebugSettings.spec.ts @@ -0,0 +1,281 @@ +import 'jasmine/src/jasmine'; + +import type { IRenderedElement } from '../src/IRenderedElement'; +import type { IRendererDelegate } from '../src/IRendererDelegate'; +import type { RendererObserver } from '../src/IRenderer'; +import { NodePrototype } from '../src/NodePrototype'; +import { Renderer } from '../src/Renderer'; +import type { ValdiRuntime } from '../src/ValdiRuntime'; +import type { DebugSettingsSnapshot } from '../src/debugging/DebugSettings'; +import { NativeAppearanceDebugSettings, NativeLayoutDirection } from '../src/debugging/NativeAppearanceDebugSettings'; + +declare const runtime: ValdiRuntime; + +interface NativeAppearanceTestGlobal { + __VALDI_DEBUG_SETTINGS__?: { + getSnapshot(): DebugSettingsSnapshot; + resetValue(groupId: string, settingId: string): DebugSettingsSnapshot; + setValue(groupId: string, settingId: string, value: unknown): DebugSettingsSnapshot; + }; +} + +interface NativeAppearanceTestRoot { + readonly attributes: Record; + readonly element: IRenderedElement; + readonly setDirections: unknown[]; +} + +interface NativeAppearanceTestRenderer { + readonly observers: RendererObserver[]; + readonly renderedDirectionElements: Set; + readonly renderCompletions: (() => void)[]; + renderer: Renderer; + readonly roots: NativeAppearanceTestRoot[]; +} + +function makeNativeAppearanceTestRoot(direction: unknown): NativeAppearanceTestRoot { + const attributes: Record = { direction }; + const setDirections: unknown[] = []; + const element = { + getAttribute: (name: string): unknown => attributes[name], + setAttribute: (name: string, value: unknown): boolean => { + if (attributes[name] === value) { + return false; + } + attributes[name] = value; + if (name === 'direction') { + setDirections.push(value); + } + return true; + }, + } as unknown as IRenderedElement; + return { attributes, element, setDirections }; +} + +function makeNativeAppearanceTestRenderer(directions: readonly unknown[]): NativeAppearanceTestRenderer { + const state: NativeAppearanceTestRenderer = { + observers: [], + renderedDirectionElements: new Set(), + renderCompletions: [], + renderer: undefined as unknown as Renderer, + roots: directions.map(makeNativeAppearanceTestRoot), + }; + state.renderer = { + addObserver: (observer: RendererObserver): void => { + state.observers.push(observer); + }, + getRootElements: (): IRenderedElement[] => state.roots.map(root => root.element), + onRenderComplete: (callback: () => void): void => { + state.renderCompletions.push(callback); + }, + removeObserver: (observer: RendererObserver): void => { + const index = state.observers.indexOf(observer); + if (index >= 0) { + state.observers.splice(index, 1); + } + }, + wasElementDirectionSetDuringCurrentRender: (element: IRenderedElement): boolean => { + return state.renderedDirectionElements.delete(element); + }, + } as unknown as Renderer; + return state; +} + +function completeNativeAppearanceTestRender(renderer: NativeAppearanceTestRenderer): void { + const completions = renderer.renderCompletions.splice(0); + completions.forEach(completion => completion()); +} + +function makeNoopRendererDelegate(): IRendererDelegate { + return new Proxy( + {}, + { + get: () => () => {}, + }, + ) as IRendererDelegate; +} + +describe('NativeAppearanceDebugSettings', () => { + let previousDebugEnabled: boolean; + let settings: NativeAppearanceDebugSettings; + + beforeEach(() => { + previousDebugEnabled = runtime.isDebugEnabled; + runtime.isDebugEnabled = true; + settings = new NativeAppearanceDebugSettings(); + }); + + afterEach(() => { + settings.dispose(); + runtime.isDebugEnabled = previousDebugEnabled; + }); + + it('publishes an explicit system/LTR/RTL setting and restores each native root default', () => { + const android = makeNativeAppearanceTestRenderer(['inherit']); + const ios = makeNativeAppearanceTestRenderer([undefined]); + settings.addRenderer(android.renderer); + settings.addRenderer(ios.renderer); + const bridge = (globalThis as NativeAppearanceTestGlobal).__VALDI_DEBUG_SETTINGS__; + + expect(bridge?.getSnapshot().groups).toContain( + jasmine.objectContaining({ + categoryId: 'appearance', + id: 'valdi.appearance', + label: 'Appearance', + settings: [ + jasmine.objectContaining({ + defaultValue: NativeLayoutDirection.System, + id: 'layout-direction', + kind: 'select', + label: 'Layout direction', + options: [ + { label: 'System', value: NativeLayoutDirection.System }, + { label: 'Left to right', value: NativeLayoutDirection.LeftToRight }, + { label: 'Right to left', value: NativeLayoutDirection.RightToLeft }, + ], + value: NativeLayoutDirection.System, + }), + ], + }), + ); + + bridge?.setValue('valdi.appearance', 'layout-direction', NativeLayoutDirection.RightToLeft); + expect(android.roots[0]?.attributes['direction']).toBe('rtl'); + expect(ios.roots[0]?.attributes['direction']).toBe('rtl'); + + bridge?.setValue('valdi.appearance', 'layout-direction', NativeLayoutDirection.LeftToRight); + expect(android.roots[0]?.attributes['direction']).toBe('ltr'); + expect(ios.roots[0]?.attributes['direction']).toBe('ltr'); + + bridge?.resetValue('valdi.appearance', 'layout-direction'); + expect(android.roots[0]?.attributes['direction']).toBe('inherit'); + expect(ios.roots[0]?.attributes['direction']).toBeUndefined(); + }); + + it('tracks application direction changes on newly rendered and rerendered roots', () => { + const bridge = (globalThis as NativeAppearanceTestGlobal).__VALDI_DEBUG_SETTINGS__; + bridge?.setValue('valdi.appearance', 'layout-direction', NativeLayoutDirection.RightToLeft); + const pendingRenderer = makeNativeAppearanceTestRenderer([]); + settings.addRenderer(pendingRenderer.renderer); + + const root = makeNativeAppearanceTestRoot('inherit'); + pendingRenderer.roots.push(root); + pendingRenderer.observers[0]?.onRootElementWillEndRender?.(); + completeNativeAppearanceTestRender(pendingRenderer); + expect(root.attributes['direction']).toBe('rtl'); + + root.attributes['direction'] = 'application'; + pendingRenderer.renderedDirectionElements.add(root.element); + pendingRenderer.observers[0]?.onRootElementWillEndRender?.(); + completeNativeAppearanceTestRender(pendingRenderer); + expect(root.attributes['direction']).toBe('rtl'); + + bridge?.resetValue('valdi.appearance', 'layout-direction'); + expect(root.attributes['direction']).toBe('application'); + }); + + it('updates and restores every top-level root immediately', () => { + const renderer = makeNativeAppearanceTestRenderer(['first', 'second']); + settings.addRenderer(renderer.renderer); + const bridge = (globalThis as NativeAppearanceTestGlobal).__VALDI_DEBUG_SETTINGS__; + + bridge?.setValue('valdi.appearance', 'layout-direction', NativeLayoutDirection.LeftToRight); + expect(renderer.roots.map(root => root.attributes['direction'])).toEqual(['ltr', 'ltr']); + + bridge?.resetValue('valdi.appearance', 'layout-direction'); + expect(renderer.roots.map(root => root.attributes['direction'])).toEqual(['first', 'second']); + }); + + it('restores a direction rerendered to the same value as the active override', () => { + const renderer = new Renderer('appearance-test', undefined, makeNoopRendererDelegate()); + const node = new NodePrototype('view', 'view'); + settings.addRenderer(renderer); + const renderDirection = (direction: string): void => { + renderer.begin(); + renderer.beginElement(node); + renderer.setAttributeString('direction', direction); + renderer.endElement(); + renderer.end(); + }; + + renderDirection('inherit'); + const bridge = (globalThis as NativeAppearanceTestGlobal).__VALDI_DEBUG_SETTINGS__; + bridge?.setValue('valdi.appearance', 'layout-direction', NativeLayoutDirection.RightToLeft); + expect(renderer.getRootElements()[0]?.getAttribute('direction')).toBe('rtl'); + + renderDirection('rtl'); + expect(renderer.getRootElements()[0]?.getAttribute('direction')).toBe('rtl'); + + bridge?.resetValue('valdi.appearance', 'layout-direction'); + expect(renderer.getRootElements()[0]?.getAttribute('direction')).toBe('rtl'); + }); + + it('prunes replaced roots while preserving the replacement root default', () => { + const renderer = makeNativeAppearanceTestRenderer(['original']); + const originalRoot = renderer.roots[0]!; + settings.addRenderer(renderer.renderer); + const bridge = (globalThis as NativeAppearanceTestGlobal).__VALDI_DEBUG_SETTINGS__; + bridge?.setValue('valdi.appearance', 'layout-direction', NativeLayoutDirection.RightToLeft); + const originalRootSetCount = originalRoot.setDirections.length; + + const replacementRoot = makeNativeAppearanceTestRoot('replacement'); + renderer.roots.push(replacementRoot); + renderer.observers[0]?.onRootElementWillEndRender?.(); + renderer.roots.splice(0, 2, replacementRoot); + completeNativeAppearanceTestRender(renderer); + expect(originalRoot.attributes['direction']).toBe('rtl'); + expect(originalRoot.setDirections.length).toBe(originalRootSetCount); + expect(replacementRoot.attributes['direction']).toBe('rtl'); + + bridge?.setValue('valdi.appearance', 'layout-direction', NativeLayoutDirection.LeftToRight); + bridge?.resetValue('valdi.appearance', 'layout-direction'); + expect(originalRoot.attributes['direction']).toBe('rtl'); + expect(originalRoot.setDirections.length).toBe(originalRootSetCount); + expect(replacementRoot.attributes['direction']).toBe('replacement'); + }); + + it('replaces the prior observer when the same renderer is added twice', () => { + const renderer = makeNativeAppearanceTestRenderer(['application']); + const removeFirst = settings.addRenderer(renderer.renderer); + const firstObserver = renderer.observers[0]; + + const removeSecond = settings.addRenderer(renderer.renderer); + expect(renderer.observers.length).toBe(1); + expect(renderer.observers[0]).not.toBe(firstObserver); + + removeFirst(); + expect(renderer.observers.length).toBe(1); + + const bridge = (globalThis as NativeAppearanceTestGlobal).__VALDI_DEBUG_SETTINGS__; + bridge?.setValue('valdi.appearance', 'layout-direction', NativeLayoutDirection.RightToLeft); + expect(renderer.roots[0]?.attributes['direction']).toBe('rtl'); + + removeSecond(); + expect(renderer.observers.length).toBe(0); + expect(renderer.roots[0]?.attributes['direction']).toBe('application'); + }); + + it('stops updating disposed roots and unregisters the framework setting', () => { + const disposedRenderer = makeNativeAppearanceTestRenderer(['application']); + const removeRenderer = settings.addRenderer(disposedRenderer.renderer); + const bridge = (globalThis as NativeAppearanceTestGlobal).__VALDI_DEBUG_SETTINGS__; + bridge?.setValue('valdi.appearance', 'layout-direction', NativeLayoutDirection.RightToLeft); + + removeRenderer(); + bridge?.setValue('valdi.appearance', 'layout-direction', NativeLayoutDirection.LeftToRight); + expect(disposedRenderer.roots[0]?.attributes['direction']).toBe('application'); + expect(disposedRenderer.observers.length).toBe(0); + + settings.dispose(); + expect((globalThis as NativeAppearanceTestGlobal).__VALDI_DEBUG_SETTINGS__).toBeUndefined(); + }); + + it('does not expose a mutable layout override when runtime debugging is disabled', () => { + settings.dispose(); + runtime.isDebugEnabled = false; + + settings = new NativeAppearanceDebugSettings(); + + expect((globalThis as NativeAppearanceTestGlobal).__VALDI_DEBUG_SETTINGS__).toBeUndefined(); + }); +});