From a9d544e6f8d9050233827d461c7e42cc605e0867 Mon Sep 17 00:00:00 2001 From: Lukas Maurer Date: Tue, 18 Aug 2026 14:32:51 +0200 Subject: [PATCH 1/6] refactor(core): fix check annotations --- packages/vue/src/tab-panel.ts | 61 +++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 packages/vue/src/tab-panel.ts diff --git a/packages/vue/src/tab-panel.ts b/packages/vue/src/tab-panel.ts new file mode 100644 index 00000000000..1e691860256 --- /dev/null +++ b/packages/vue/src/tab-panel.ts @@ -0,0 +1,61 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import type { JSX } from '@siemens/ix'; +import { defineCustomElement as defineIxTabPanel } from '@siemens/ix/components/ix-tab-panel.js'; +import { + defineContainer, + type StencilVueComponent, +} from '@stencil/vue-output-target/runtime'; +import { + defineComponent, + h, + inject, + type InjectionKey, + type PropType, + type Ref, +} from 'vue'; + +const InternalIxTabPanel: StencilVueComponent = + // eslint-disable-next-line no-inline-comments + /*@__PURE__*/ defineContainer( + 'ix-tab-panel', + defineIxTabPanel, + ['tabKey'] + ); + +export const ixTabSetActiveKey: InjectionKey> = Symbol( + 'ix-tab-set-active-key' +); + +export const IxTabPanel = defineComponent({ + name: 'IxTabPanel', + inheritAttrs: false, + props: { + tabKey: { + type: String as PropType, + required: true, + }, + }, + setup(props, { attrs, slots }) { + const activeTabKey = inject(ixTabSetActiveKey, null); + + return () => + h( + InternalIxTabPanel, + { + ...attrs, + tabKey: props.tabKey, + }, + activeTabKey === null || activeTabKey.value === props.tabKey + ? slots + : undefined + ); + }, +}); From 981d0d2d86672b9cee49897ecfbe15f840d14751 Mon Sep 17 00:00:00 2001 From: Lukas Maurer Date: Tue, 18 Aug 2026 14:35:44 +0200 Subject: [PATCH 2/6] refactor(core): fix check annotations --- .changeset/lazy-vue-tabs.md | 5 + packages/react/src/delegate.ts | 35 ++- packages/vue/src/index.ts | 1 + packages/vue/src/modal/index.ts | 2 +- packages/vue/src/tab-set.ts | 44 +--- packages/vue/src/vue-component-lib/utils.ts | 244 -------------------- 6 files changed, 34 insertions(+), 297 deletions(-) create mode 100644 .changeset/lazy-vue-tabs.md delete mode 100644 packages/vue/src/vue-component-lib/utils.ts diff --git a/.changeset/lazy-vue-tabs.md b/.changeset/lazy-vue-tabs.md new file mode 100644 index 00000000000..0699c83b9d7 --- /dev/null +++ b/.changeset/lazy-vue-tabs.md @@ -0,0 +1,5 @@ +--- +'@siemens/ix-vue': minor +--- + +Expose `IxTabSet` and `IxTabPanel` wrappers with lazy rendering of inactive tab panel content. diff --git a/packages/react/src/delegate.ts b/packages/react/src/delegate.ts index c451cb92a59..467e7bc72b0 100644 --- a/packages/react/src/delegate.ts +++ b/packages/react/src/delegate.ts @@ -8,6 +8,7 @@ */ import type { FrameworkDelegate } from '@siemens/ix'; import { registerFrameworkDelegate } from '@siemens/ix/components'; +import type { ReactNode } from 'react'; import ReactDOMClient from 'react-dom/client'; let viewInstance = 0; @@ -17,8 +18,8 @@ function createViewInstance() { const mountedRootNodes: Record = {}; -async function fallbackRootDom(id: string, view: React.ReactNode) { - return new Promise((resolve) => { +async function fallbackRootDom(id: string, view: ReactNode): Promise { + return new Promise((resolve) => { const rootElement = document.createElement('DIV'); rootElement.id = id; rootElement.style.display = 'contents'; @@ -36,8 +37,12 @@ async function fallbackRootDom(id: string, view: React.ReactNode) { }); } -async function fallbackRemoveViewFromRootDom(view: any) { +async function fallbackRemoveViewFromRootDom(view: Element) { const parent = view.parentElement; + if (!parent) { + throw new Error('Cannot remove a view without a parent element'); + } + const id = parent.id; if (id in mountedRootNodes) { mountedRootNodes[id].unmount(); @@ -47,7 +52,7 @@ async function fallbackRemoveViewFromRootDom(view: any) { } export class ReactFrameworkDelegate implements FrameworkDelegate { - attachViewToPortal?: (id: string, view: any) => Promise; + attachViewToPortal?: (id: string, view: ReactNode) => Promise; removeViewFromPortal?: (id: string) => void; resolvePortalInitPromise: (() => void) | undefined; @@ -60,29 +65,39 @@ export class ReactFrameworkDelegate implements FrameworkDelegate { ); } - async attachView(view: any): Promise { + async attachView(view: ReactNode): Promise { const id = createViewInstance(); if (!this.isUsingReactPortal) { - return fallbackRootDom(id, view); + return (await fallbackRootDom(id, view)) as R; } await this.isPortalReady(); if (this.attachViewToPortal) { - const refElement = await this.attachViewToPortal(id, view); - return refElement; + return (await this.attachViewToPortal(id, view)) as R; } - console.error('Portal could not be initialized'); + throw new Error('React portal could not be initialized'); } - async removeView(view: any): Promise { + async removeView(view: unknown): Promise { + if (!(view instanceof Element)) { + throw new TypeError('A React framework view must be a DOM element'); + } + if (!this.removeViewFromPortal) { return fallbackRemoveViewFromRootDom(view); } const parent = view.parentElement; + if (!parent) { + throw new Error('Cannot remove a view without a parent element'); + } + const id = parent.getAttribute('data-portal-id'); + if (!id) { + throw new Error('Cannot remove a portal view without a portal identifier'); + } this.removeViewFromPortal(id); } diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 7f472eb48a8..0d2ec636e17 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -4,6 +4,7 @@ export * from './internal-components'; export * from './ix-icon'; export * from './modal'; export * from './plugin'; +export * from './tab-set'; export * from './toast'; export * from './message'; diff --git a/packages/vue/src/modal/index.ts b/packages/vue/src/modal/index.ts index fa66b9c84f3..4d2d1d432f8 100644 --- a/packages/vue/src/modal/index.ts +++ b/packages/vue/src/modal/index.ts @@ -52,7 +52,7 @@ export function dismissModal(modalInstance: IxModalInstance) { } } -export function closeModal( +export function closeModal( modalInstance: IxModalInstance, reason?: T ) { diff --git a/packages/vue/src/tab-set.ts b/packages/vue/src/tab-set.ts index daa25215f99..84431f49a1c 100644 --- a/packages/vue/src/tab-set.ts +++ b/packages/vue/src/tab-set.ts @@ -12,12 +12,10 @@ import { defineContainer, type StencilVueComponent, } from '@stencil/vue-output-target/runtime'; -import { defineCustomElement as defineIxTabPanel } from '@siemens/ix/components/ix-tab-panel.js'; import { defineCustomElement as defineIxTabSet } from '@siemens/ix/components/ix-tab-set.js'; import { defineComponent, h, - inject, isVNode, nextTick, onBeforeUnmount, @@ -25,25 +23,16 @@ import { onUpdated, provide, ref, - type PropType, - type Ref, type VNode, } from 'vue'; +import { ixTabSetActiveKey } from './tab-panel'; -const InternalIxTabPanel: StencilVueComponent = - // eslint-disable-next-line no-inline-comments - /*@__PURE__*/ defineContainer( - 'ix-tab-panel', - defineIxTabPanel, - ['tabKey'] - ); +export { IxTabPanel } from './tab-panel'; const InternalIxTabSet: StencilVueComponent = // eslint-disable-next-line no-inline-comments /*@__PURE__*/ defineContainer('ix-tab-set', defineIxTabSet); -const ixTabSetActiveKey = Symbol('ix-tab-set-active-key'); - type HTMLRefElement = { $el: T }; function normalizeChildren(children: unknown): VNode[] { @@ -174,32 +163,3 @@ export const IxTabSet = defineComponent({ ); }, }); - -export const IxTabPanel = defineComponent({ - name: 'IxTabPanel', - inheritAttrs: false, - props: { - tabKey: { - type: String as PropType, - required: true, - }, - }, - setup(props, { attrs, slots }) { - const activeTabKey = inject | null>( - ixTabSetActiveKey, - null - ); - - return () => - h( - InternalIxTabPanel, - { - ...attrs, - tabKey: props.tabKey, - }, - activeTabKey === null || activeTabKey.value === props.tabKey - ? slots - : undefined - ); - }, -}); diff --git a/packages/vue/src/vue-component-lib/utils.ts b/packages/vue/src/vue-component-lib/utils.ts deleted file mode 100644 index 15c05af1e81..00000000000 --- a/packages/vue/src/vue-component-lib/utils.ts +++ /dev/null @@ -1,244 +0,0 @@ -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-nocheck -// It's easier and safer for Volar to disable typechecking and let the return type inference do its job. -import { - defineComponent, - getCurrentInstance, - h, - inject, - ref, - Ref, - withDirectives, -} from 'vue'; - -export interface InputProps { - modelValue?: T; -} - -const UPDATE_VALUE_EVENT = 'update:modelValue'; -const MODEL_VALUE = 'modelValue'; -const ROUTER_LINK_VALUE = 'routerLink'; -const NAV_MANAGER = 'navManager'; -const ROUTER_PROP_PREFIX = 'router'; -const ARIA_PROP_PREFIX = 'aria'; -/** - * Starting in Vue 3.1.0, all properties are - * added as keys to the props object, even if - * they are not being used. In order to correctly - * account for both value props and v-model props, - * we need to check if the key exists for Vue <3.1.0 - * and then check if it is not undefined for Vue >= 3.1.0. - * See https://github.com/vuejs/vue-next/issues/3889 - */ -const EMPTY_PROP = Symbol(); -const DEFAULT_EMPTY_PROP = { default: EMPTY_PROP }; - -interface NavManager { - navigate: (options: T) => void; -} - -const getComponentClasses = (classes: unknown) => { - return (classes as string)?.split(' ') || []; -}; - -const getElementClasses = ( - ref: Ref, - componentClasses: Set, - defaultClasses: string[] = [] -) => { - return [...Array.from(ref.value?.classList || []), ...defaultClasses].filter( - (c: string, i, self) => !componentClasses.has(c) && self.indexOf(c) === i - ); -}; - -/** - * Create a callback to define a Vue component wrapper around a Web Component. - * - * @prop name - The component tag name (i.e. `ion-button`) - * @prop componentProps - An array of properties on the - * component. These usually match up with the @Prop definitions - * in each component's TSX file. - * @prop customElement - An option custom element instance to pass - * to customElements.define. Only set if `includeImportCustomElements: true` in your config. - * @prop modelProp - The prop that v-model binds to (i.e. value) - * @prop modelUpdateEvent - The event that is fired from your Web Component when the value changes (i.e. ionChange) - */ -export const defineContainer = ( - name: string, - defineCustomElement: any, - componentProps: string[] = [], - modelProp?: string, - modelUpdateEvent?: string -) => { - /** - * Create a Vue component wrapper around a Web Component. - * Note: The `props` here are not all properties on a component. - * They refer to whatever properties are set on an instance of a component. - */ - - if (defineCustomElement !== undefined) { - defineCustomElement(); - } - - const Container = defineComponent>( - (props, { attrs, slots, emit }) => { - let modelPropValue = props[modelProp]; - const containerRef = ref(); - const classes = new Set(getComponentClasses(attrs.class)); - - /** - * This directive is responsible for updating any reactive - * reference associated with v-model on the component. - * This code must be run inside of the "created" callback. - * Since the following listener callbacks as well as any potential - * event callback defined in the developer's app are set on - * the same element, we need to make sure the following callbacks - * are set first so they fire first. If the developer's callback fires first - * then the reactive reference will not have been updated yet. - */ - const vModelDirective = { - created: (el: HTMLElement) => { - const eventsNames = Array.isArray(modelUpdateEvent) - ? modelUpdateEvent - : [modelUpdateEvent]; - eventsNames.forEach((eventName: string) => { - // Remove eventName.toLowerCase() from original 0.8.8 to not mutate event naming - el.addEventListener(eventName, (e: Event) => { - /** - * Only update the v-model binding if the event's target is the element we are - * listening on. For example, Component A could emit ionChange, but it could also - * have a descendant Component B that also emits ionChange. We only want to update - * the v-model for Component A when ionChange originates from that element and not - * when ionChange bubbles up from Component B. - */ - if (e.target.tagName === el.tagName) { - modelPropValue = (e?.target as any)[modelProp]; - emit(UPDATE_VALUE_EVENT, modelPropValue); - } - }); - }); - }, - }; - - const currentInstance = getCurrentInstance(); - const hasRouter = currentInstance?.appContext?.provides[NAV_MANAGER]; - const navManager: NavManager | undefined = hasRouter - ? inject(NAV_MANAGER) - : undefined; - const handleRouterLink = (ev: Event) => { - const { routerLink } = props; - if (routerLink === EMPTY_PROP) return; - - if (navManager !== undefined) { - const navigationPayload: any = { event: ev }; - for (const key in props) { - const value = props[key]; - if ( - // eslint-disable-next-line no-prototype-builtins - props.hasOwnProperty(key) && - key.startsWith(ROUTER_PROP_PREFIX) && - value !== EMPTY_PROP - ) { - navigationPayload[key] = value; - } - } - - navManager.navigate(navigationPayload); - } else { - console.warn( - 'Tried to navigate, but no router was found. Make sure you have mounted Vue Router.' - ); - } - }; - - return () => { - modelPropValue = props[modelProp]; - - getComponentClasses(attrs.class).forEach((value) => { - classes.add(value); - }); - - const oldClick = props.onClick; - const handleClick = (ev: Event) => { - if (oldClick !== undefined) { - oldClick(ev); - } - if (!ev.defaultPrevented) { - handleRouterLink(ev); - } - }; - - let propsToAdd: any = { - ref: containerRef, - class: getElementClasses(containerRef, classes), - onClick: handleClick, - }; - - /** - * We can use Object.entries here - * to avoid the hasOwnProperty check, - * but that would require 2 iterations - * where as this only requires 1. - */ - for (const key in props) { - const value = props[key]; - if ( - // eslint-disable-next-line no-prototype-builtins - (props.hasOwnProperty(key) && value !== EMPTY_PROP) || - key.startsWith(ARIA_PROP_PREFIX) - ) { - propsToAdd[key] = value; - } - } - - if (modelProp) { - /** - * If form value property was set using v-model - * then we should use that value. - * Otherwise, check to see if form value property - * was set as a static value (i.e. no v-model). - */ - if (props[MODEL_VALUE] !== EMPTY_PROP) { - propsToAdd = { - ...propsToAdd, - [modelProp]: props[MODEL_VALUE], - }; - } else if (modelPropValue !== EMPTY_PROP) { - propsToAdd = { - ...propsToAdd, - [modelProp]: modelPropValue, - }; - } - } - - /** - * vModelDirective is only needed on components that support v-model. - * As a result, we conditionally call withDirectives with v-model components. - */ - const node = h(name, propsToAdd, slots.default && slots.default()); - return modelProp === undefined - ? node - : withDirectives(node, [[vModelDirective]]); - }; - } - ); - - if (typeof Container !== 'function') { - Container.name = name; - - Container.props = { - [ROUTER_LINK_VALUE]: DEFAULT_EMPTY_PROP, - }; - - componentProps.forEach((componentProp) => { - Container.props[componentProp] = DEFAULT_EMPTY_PROP; - }); - - if (modelProp) { - Container.props[MODEL_VALUE] = DEFAULT_EMPTY_PROP; - Container.emits = [UPDATE_VALUE_EVENT]; - } - } - - return Container; -}; From d9b29afd65fadfed80c0963c24e3aff10ba7b951 Mon Sep 17 00:00:00 2001 From: Lukas Maurer Date: Tue, 18 Aug 2026 14:49:50 +0200 Subject: [PATCH 3/6] refactor(core): lint --- packages/ionic-test-app/eslint.config.cjs | 7 ++++++- packages/react/src/delegate.ts | 4 +++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/ionic-test-app/eslint.config.cjs b/packages/ionic-test-app/eslint.config.cjs index 1873c42740b..4644ba3764f 100644 --- a/packages/ionic-test-app/eslint.config.cjs +++ b/packages/ionic-test-app/eslint.config.cjs @@ -17,7 +17,12 @@ const compat = new FlatCompat({ module.exports = [ { - ignores: ['dist/**', 'ios/**', 'node_modules/**'], + ignores: [ + 'dist/**', + 'ios/**', + 'node_modules/**', + 'public/additional-theme/**', + ], }, ...compat.config({ env: { diff --git a/packages/react/src/delegate.ts b/packages/react/src/delegate.ts index 467e7bc72b0..67169691bae 100644 --- a/packages/react/src/delegate.ts +++ b/packages/react/src/delegate.ts @@ -96,7 +96,9 @@ export class ReactFrameworkDelegate implements FrameworkDelegate { const id = parent.getAttribute('data-portal-id'); if (!id) { - throw new Error('Cannot remove a portal view without a portal identifier'); + throw new Error( + 'Cannot remove a portal view without a portal identifier' + ); } this.removeViewFromPortal(id); From 13177aefa70f459489c812053b839e62264c64b1 Mon Sep 17 00:00:00 2001 From: Lukas Maurer Date: Thu, 20 Aug 2026 11:02:36 +0200 Subject: [PATCH 4/6] fix(frameworks): cover wrapper regressions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .changeset/clean-react-modal-roots.md | 5 + .changeset/type-safe-vue-close-modal.md | 5 + packages/react/src/delegate.ts | 97 ++++++++- packages/react/src/modal/index.ts | 2 +- .../src/tests/delegate/delegate.test.tsx | 192 ++++++++++++++++++ .../src/tests/tabs/lazy-tab-set.test.tsx | 6 +- .../react/src/tests/toasts/toast.spec.tsx | 6 +- packages/vue/src/tests/tab-panel.spec.ts | 94 +++++++++ 8 files changed, 394 insertions(+), 13 deletions(-) create mode 100644 .changeset/clean-react-modal-roots.md create mode 100644 .changeset/type-safe-vue-close-modal.md create mode 100644 packages/react/src/tests/delegate/delegate.test.tsx create mode 100644 packages/vue/src/tests/tab-panel.spec.ts diff --git a/.changeset/clean-react-modal-roots.md b/.changeset/clean-react-modal-roots.md new file mode 100644 index 00000000000..ae91ca39b51 --- /dev/null +++ b/.changeset/clean-react-modal-roots.md @@ -0,0 +1,5 @@ +--- +'@siemens/ix-react': patch +--- + +Preserve support for HTMLElement modal content, clean up failed React renders, and report invalid view removal or portal initialization errors. diff --git a/.changeset/type-safe-vue-close-modal.md b/.changeset/type-safe-vue-close-modal.md new file mode 100644 index 00000000000..932a8251c8b --- /dev/null +++ b/.changeset/type-safe-vue-close-modal.md @@ -0,0 +1,5 @@ +--- +'@siemens/ix-vue': patch +--- + +Use `unknown` as the default reason type for the exported `closeModal` helper. diff --git a/packages/react/src/delegate.ts b/packages/react/src/delegate.ts index 67169691bae..7373bc41060 100644 --- a/packages/react/src/delegate.ts +++ b/packages/react/src/delegate.ts @@ -9,31 +9,96 @@ import type { FrameworkDelegate } from '@siemens/ix'; import { registerFrameworkDelegate } from '@siemens/ix/components'; import type { ReactNode } from 'react'; +import { createElement, Fragment, useLayoutEffect } from 'react'; import ReactDOMClient from 'react-dom/client'; let viewInstance = 0; +export const ATTACH_VIEW_TIMEOUT_MS = 5000; + function createViewInstance() { return `ix-react-view-${viewInstance++}`; } const mountedRootNodes: Record = {}; +const mountedDomViews = new WeakSet(); + +function CommitSignal({ onCommit }: { onCommit: () => void }) { + useLayoutEffect(onCommit, [onCommit]); + return null; +} -async function fallbackRootDom(id: string, view: ReactNode): Promise { - return new Promise((resolve) => { +async function fallbackRootDom( + id: string, + view: ReactNode +): Promise { + return new Promise((resolve, reject) => { const rootElement = document.createElement('DIV'); rootElement.id = id; rootElement.style.display = 'contents'; document.body.appendChild(rootElement); const root = ReactDOMClient.createRoot(rootElement); - root.render(view); - mountedRootNodes[id] = root; - setTimeout(() => { + let settled = false; + + const cleanup = () => { + clearTimeout(timeoutId); + root.unmount(); + delete mountedRootNodes[id]; + rootElement.remove(); + }; + + const settleResolve = (value: Element) => { + if (settled) { + return; + } + settled = true; + resolve(value); + }; + + const settleReject = (error: unknown) => { + if (settled) { + return; + } + settled = true; + cleanup(); + reject(error); + }; + + const timeoutId = setTimeout(() => { + settleReject( + new Error( + `React view did not commit within ${ATTACH_VIEW_TIMEOUT_MS}ms` + ) + ); + }, ATTACH_VIEW_TIMEOUT_MS); + + const onCommit = () => { const viewElement = rootElement.children[0]; - resolve(viewElement); - }); + if (!(viewElement instanceof Element)) { + queueMicrotask(() => { + settleReject(new Error('React view did not render a host element')); + }); + return; + } + + clearTimeout(timeoutId); + settleResolve(viewElement); + }; + + try { + root.render( + createElement( + Fragment, + null, + view, + createElement(CommitSignal, { onCommit }) + ) + ); + } catch (error) { + settleReject(error); + } }); } @@ -65,7 +130,17 @@ export class ReactFrameworkDelegate implements FrameworkDelegate { ); } - async attachView(view: ReactNode): Promise { + async attachView( + view: ReactNode | HTMLElement + ): Promise { + if (view instanceof HTMLElement) { + if (!view.isConnected) { + document.body.appendChild(view); + mountedDomViews.add(view); + } + return view as R; + } + const id = createViewInstance(); if (!this.isUsingReactPortal) { @@ -85,6 +160,12 @@ export class ReactFrameworkDelegate implements FrameworkDelegate { throw new TypeError('A React framework view must be a DOM element'); } + if (mountedDomViews.has(view)) { + mountedDomViews.delete(view); + view.remove(); + return; + } + if (!this.removeViewFromPortal) { return fallbackRemoveViewFromRootDom(view); } diff --git a/packages/react/src/modal/index.ts b/packages/react/src/modal/index.ts index 9b56f6eec6f..35c9b50f102 100644 --- a/packages/react/src/modal/index.ts +++ b/packages/react/src/modal/index.ts @@ -39,7 +39,7 @@ const showModalLoadingWithDependencies = createShowModalLoading([ export * from './modal'; export type ModalConfig = { - content: React.ReactNode | string; + content: React.ReactNode | HTMLElement; }; export async function showModal( diff --git a/packages/react/src/tests/delegate/delegate.test.tsx b/packages/react/src/tests/delegate/delegate.test.tsx new file mode 100644 index 00000000000..fa081d95322 --- /dev/null +++ b/packages/react/src/tests/delegate/delegate.test.tsx @@ -0,0 +1,192 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { createPortal } from 'react-dom'; +import type { ReactNode } from 'react'; +import { cleanup, render, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + ATTACH_VIEW_TIMEOUT_MS, + ReactFrameworkDelegate, + reactFrameworkDelegate, +} from '../../delegate'; +import { showModal } from '../../modal'; +import { IxOverlay, PORTAL_ID } from '../../modal/portal'; + +const rootSelector = '[id^="ix-react-view-"]'; + +afterEach(() => { + cleanup(); + reactFrameworkDelegate.isUsingReactPortal = false; + document + .querySelectorAll(rootSelector) + .forEach((element) => element.remove()); + vi.restoreAllMocks(); +}); + +describe('ReactFrameworkDelegate', () => { + it('rejects an empty view and removes its root container', async () => { + const delegate = new ReactFrameworkDelegate(); + + await expect(delegate.attachView(null)).rejects.toThrow( + 'React view did not render a host element' + ); + + expect(document.querySelector(rootSelector)).toBeNull(); + }); + + it('rejects a portal-only view and unmounts its portal content', async () => { + const delegate = new ReactFrameworkDelegate(); + const portalContainer = document.createElement('div'); + document.body.appendChild(portalContainer); + + await expect( + delegate.attachView( + createPortal(
Portal content
, portalContainer) as ReactNode + ) + ).rejects.toThrow('React view did not render a host element'); + + expect(document.querySelector(rootSelector)).toBeNull(); + expect(portalContainer).toBeEmptyDOMElement(); + portalContainer.remove(); + }); + + it('waits for React to commit before resolving a delayed render', async () => { + const delegate = new ReactFrameworkDelegate(); + + const viewPromise = delegate.attachView(
Delayed content
); + + await waitFor(async () => { + const view = await viewPromise; + expect(view).toHaveTextContent('Delayed content'); + }); + + const view = await viewPromise; + await delegate.removeView(view); + }); + + it('rejects with a timeout and cleans up the root container when the view never commits', async () => { + vi.useFakeTimers(); + + function NeverCommits() { + throw new Promise(() => { + // never resolves, so the render never commits + }); + } + + const delegate = new ReactFrameworkDelegate(); + const assertion = expect( + delegate.attachView() + ).rejects.toThrow( + `React view did not commit within ${ATTACH_VIEW_TIMEOUT_MS}ms` + ); + + await vi.advanceTimersByTimeAsync(ATTACH_VIEW_TIMEOUT_MS); + await assertion; + + vi.useRealTimers(); + expect(document.querySelector(rootSelector)).toBeNull(); + }); + + it('rejects non-DOM views', async () => { + const delegate = new ReactFrameworkDelegate(); + + await expect(delegate.removeView({})).rejects.toThrow( + new TypeError('A React framework view must be a DOM element') + ); + }); + + it('rejects detached fallback views', async () => { + const delegate = new ReactFrameworkDelegate(); + + await expect( + delegate.removeView(document.createElement('div')) + ).rejects.toThrow('Cannot remove a view without a parent element'); + }); + + it('removes the fallback root from the DOM', async () => { + const delegate = new ReactFrameworkDelegate(); + const view = await delegate.attachView(
Fallback content
); + const root = view.parentElement; + + await delegate.removeView(view); + + expect(root).not.toBeNull(); + expect(root).not.toBeInTheDocument(); + expect(view).not.toBeInTheDocument(); + }); + + it('rejects portal views without a parent', async () => { + const delegate = new ReactFrameworkDelegate(); + delegate.removeViewFromPortal = vi.fn(); + + await expect( + delegate.removeView(document.createElement('div')) + ).rejects.toThrow('Cannot remove a view without a parent element'); + + expect(delegate.removeViewFromPortal).not.toHaveBeenCalled(); + }); + + it('rejects portal views without a portal identifier', async () => { + const delegate = new ReactFrameworkDelegate(); + const parent = document.createElement('div'); + const view = document.createElement('div'); + delegate.removeViewFromPortal = vi.fn(); + parent.appendChild(view); + + await expect(delegate.removeView(view)).rejects.toThrow( + 'Cannot remove a portal view without a portal identifier' + ); + + expect(delegate.removeViewFromPortal).not.toHaveBeenCalled(); + }); + + it('removes a portal view from the DOM', async () => { + const delegate = new ReactFrameworkDelegate(); + delegate.isUsingReactPortal = true; + render( + <> + +
+ + ); + const view = await delegate.attachView(
Portal content
); + const portalParent = view.parentElement; + + await delegate.removeView(view); + + await waitFor(() => { + expect(portalParent).not.toBeInTheDocument(); + expect(view).not.toBeInTheDocument(); + }); + }); +}); + +describe('showModal', () => { + it('shows and removes an HTMLElement modal', async () => { + const modal = document.createElement('ix-modal'); + + const modalInstance = await showModal({ content: modal }); + + expect(modalInstance.htmlElement).toBe(modal); + expect(modal).toBeInTheDocument(); + + await reactFrameworkDelegate.removeView(modal); + + expect(modal).not.toBeInTheDocument(); + }); + + it('does not retain a root container when React renders no modal', async () => { + await expect(showModal({ content: null })).rejects.toThrow( + 'React view did not render a host element' + ); + + expect(document.querySelector(rootSelector)).toBeNull(); + }); +}); diff --git a/packages/react/src/tests/tabs/lazy-tab-set.test.tsx b/packages/react/src/tests/tabs/lazy-tab-set.test.tsx index 65fa712d6e2..508f6ec75cb 100644 --- a/packages/react/src/tests/tabs/lazy-tab-set.test.tsx +++ b/packages/react/src/tests/tabs/lazy-tab-set.test.tsx @@ -7,10 +7,12 @@ * LICENSE file in the root directory of this source tree. */ -import { render, waitFor } from '@testing-library/react'; -import { expect, test, vi } from 'vitest'; +import { cleanup, render, waitFor } from '@testing-library/react'; +import { afterEach, expect, test, vi } from 'vitest'; import { LazyTabSetExample } from './lazy-tab-set'; +afterEach(cleanup); + test('should only mount the active tab panel content', async () => { const onMount = vi.fn(); const onUnmount = vi.fn(); diff --git a/packages/react/src/tests/toasts/toast.spec.tsx b/packages/react/src/tests/toasts/toast.spec.tsx index 702b46ad0aa..ef7ff44c21d 100644 --- a/packages/react/src/tests/toasts/toast.spec.tsx +++ b/packages/react/src/tests/toasts/toast.spec.tsx @@ -6,12 +6,14 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ -import { render, waitFor } from '@testing-library/react'; +import { cleanup, render, waitFor } from '@testing-library/react'; import { screen } from 'shadow-dom-testing-library'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import Content from './toast'; import { iconStar } from '@siemens/ix-icons/icons'; +afterEach(cleanup); + describe(`toast`, () => { it(`basic`, async () => { const { getByText } = render(); diff --git a/packages/vue/src/tests/tab-panel.spec.ts b/packages/vue/src/tests/tab-panel.spec.ts new file mode 100644 index 00000000000..440108adaab --- /dev/null +++ b/packages/vue/src/tests/tab-panel.spec.ts @@ -0,0 +1,94 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/* eslint-disable vue/one-component-per-file */ +import { cleanup, render } from '@testing-library/vue'; +import { defineComponent, h, nextTick } from 'vue'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { IxTabPanel, IxTabs, IxTabSet } from '../index'; + +afterEach(cleanup); + +function panelContent(text: string, onSetup: () => void) { + return defineComponent({ + setup() { + onSetup(); + return () => h('span', text); + }, + }); +} + +function renderTabSet(activeTabKey: string) { + const firstPanelMount = vi.fn(); + const secondPanelMount = vi.fn(); + const FirstPanelContent = panelContent('First panel', firstPanelMount); + const SecondPanelContent = panelContent('Second panel', secondPanelMount); + const result = render( + defineComponent({ + setup() { + return () => + h(IxTabSet, null, { + default: () => [ + h(IxTabs, { activeTabKey }), + h( + IxTabPanel, + { tabKey: 'first' }, + { default: () => h(FirstPanelContent) } + ), + h( + IxTabPanel, + { tabKey: 'second' }, + { default: () => h(SecondPanelContent) } + ), + ], + }); + }, + }) + ); + + return { ...result, firstPanelMount, secondPanelMount }; +} + +describe('IxTabPanel', () => { + it('is exported from the package entry point', () => { + expect(IxTabPanel).toBeDefined(); + }); + + it('only mounts the initially active panel slot', () => { + const { queryByText, firstPanelMount, secondPanelMount } = + renderTabSet('second'); + + expect(queryByText('First panel')).toBeNull(); + expect(queryByText('Second panel')).not.toBeNull(); + expect(firstPanelMount).not.toHaveBeenCalled(); + expect(secondPanelMount).toHaveBeenCalledOnce(); + }); + + it('mounts an inactive panel slot after tabChange activates it', async () => { + const { container, queryByText, firstPanelMount, secondPanelMount } = + renderTabSet('first'); + + expect(queryByText('First panel')).not.toBeNull(); + expect(queryByText('Second panel')).toBeNull(); + expect(firstPanelMount).toHaveBeenCalledOnce(); + expect(secondPanelMount).not.toHaveBeenCalled(); + + await nextTick(); + await nextTick(); + const tabSet = container.querySelector('ix-tab-set'); + expect(tabSet).not.toBeNull(); + tabSet!.dispatchEvent(new CustomEvent('tabChange', { detail: 'second' })); + await nextTick(); + + expect(queryByText('First panel')).toBeNull(); + expect(queryByText('Second panel')).not.toBeNull(); + expect(firstPanelMount).toHaveBeenCalledOnce(); + expect(secondPanelMount).toHaveBeenCalledOnce(); + }); +}); From cc67879d09b8fc666c88a13457616aefde243d01 Mon Sep 17 00:00:00 2001 From: Lukas Maurer Date: Thu, 20 Aug 2026 11:29:28 +0200 Subject: [PATCH 5/6] style(react): format delegate signatures Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/react/src/delegate.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/react/src/delegate.ts b/packages/react/src/delegate.ts index 7373bc41060..f90b00eea24 100644 --- a/packages/react/src/delegate.ts +++ b/packages/react/src/delegate.ts @@ -27,10 +27,7 @@ function CommitSignal({ onCommit }: { onCommit: () => void }) { return null; } -async function fallbackRootDom( - id: string, - view: ReactNode -): Promise { +async function fallbackRootDom(id: string, view: ReactNode): Promise { return new Promise((resolve, reject) => { const rootElement = document.createElement('DIV'); rootElement.id = id; @@ -130,9 +127,7 @@ export class ReactFrameworkDelegate implements FrameworkDelegate { ); } - async attachView( - view: ReactNode | HTMLElement - ): Promise { + async attachView(view: ReactNode | HTMLElement): Promise { if (view instanceof HTMLElement) { if (!view.isConnected) { document.body.appendChild(view); From 17a05710b9f211a35cf4dc7bbc8217f285c0d591 Mon Sep 17 00:00:00 2001 From: Lukas Maurer Date: Thu, 20 Aug 2026 15:06:29 +0200 Subject: [PATCH 6/6] fix(core): change any to unknown --- .changeset/type-safe-react-callbacks.md | 5 +++ BREAKING_CHANGES.md | 1 + BREAKING_CHANGES/v6.md | 15 ++++++++ packages/react/src/delegate.ts | 6 ++-- packages/react/src/modal/index.ts | 2 +- packages/react/src/modal/modal.tsx | 4 +-- packages/react/src/modal/portal.tsx | 8 ++--- .../src/tests/delegate/delegate.test.tsx | 35 +++++++++++++++---- .../react/src/tests/toasts/toast.spec.tsx | 9 ++++- packages/react/src/tree/internal-tree.tsx | 2 +- 10 files changed, 67 insertions(+), 20 deletions(-) create mode 100644 .changeset/type-safe-react-callbacks.md create mode 100644 BREAKING_CHANGES/v6.md diff --git a/.changeset/type-safe-react-callbacks.md b/.changeset/type-safe-react-callbacks.md new file mode 100644 index 00000000000..e9dc8c3df59 --- /dev/null +++ b/.changeset/type-safe-react-callbacks.md @@ -0,0 +1,5 @@ +--- +'@siemens/ix-react': major +--- + +Use `unknown` instead of `any` for modal result defaults and tree callback values so consumers explicitly narrow untyped data. diff --git a/BREAKING_CHANGES.md b/BREAKING_CHANGES.md index 0bf8c89505b..84a5e6f3bd9 100644 --- a/BREAKING_CHANGES.md +++ b/BREAKING_CHANGES.md @@ -7,6 +7,7 @@ This document aims to provide a clear and detailed overview of all significant m Please select your target version +- [Version v6.0.0](./BREAKING_CHANGES/v6.md) - [Version v5.0.0](./BREAKING_CHANGES/v5.md) - [Version v4.0.0](./BREAKING_CHANGES/v4.md) - [Version v3.0.0](./BREAKING_CHANGES/v3.md) diff --git a/BREAKING_CHANGES/v6.md b/BREAKING_CHANGES/v6.md new file mode 100644 index 00000000000..9a47f124bff --- /dev/null +++ b/BREAKING_CHANGES/v6.md @@ -0,0 +1,15 @@ +# Breaking Changes V6 + +This document lists breaking changes introduced in Siemens Industrial Experience V6. + +## React callback types + +The React wrapper now uses `unknown` instead of `any` for untyped public values: + +- `closeModal`, `ModalRef.close`, and `ModalRef.dismiss` default their result + type to `unknown`. +- `IxTreeProps.renderItem` receives `unknown`. +- `IxTreeProps.onNodeRemoved` receives `CustomEvent`. + +Specify generic result types where needed and narrow callback values before +accessing their properties. diff --git a/packages/react/src/delegate.ts b/packages/react/src/delegate.ts index f90b00eea24..5e1131224c9 100644 --- a/packages/react/src/delegate.ts +++ b/packages/react/src/delegate.ts @@ -129,10 +129,8 @@ export class ReactFrameworkDelegate implements FrameworkDelegate { async attachView(view: ReactNode | HTMLElement): Promise { if (view instanceof HTMLElement) { - if (!view.isConnected) { - document.body.appendChild(view); - mountedDomViews.add(view); - } + document.body.appendChild(view); + mountedDomViews.add(view); return view as R; } diff --git a/packages/react/src/modal/index.ts b/packages/react/src/modal/index.ts index 35c9b50f102..0d450e3927b 100644 --- a/packages/react/src/modal/index.ts +++ b/packages/react/src/modal/index.ts @@ -60,7 +60,7 @@ export function dismissModal(modalInstance: IxModalInstance) { } } -export function closeModal( +export function closeModal( modalInstance: IxModalInstance, reason?: T ) { diff --git a/packages/react/src/modal/modal.tsx b/packages/react/src/modal/modal.tsx index 7717473eb79..4038af43b77 100644 --- a/packages/react/src/modal/modal.tsx +++ b/packages/react/src/modal/modal.tsx @@ -11,8 +11,8 @@ import React, { useImperativeHandle, useRef } from 'react'; import { IxModal } from '../components'; export interface ModalRef { - close: (result: T) => void; - dismiss: (result?: T) => void; + close: (result: T) => void; + dismiss: (result?: T) => void; modalElement: HTMLIxModalElement | null; } diff --git a/packages/react/src/modal/portal.tsx b/packages/react/src/modal/portal.tsx index 2bfa9f794a1..40df438348c 100644 --- a/packages/react/src/modal/portal.tsx +++ b/packages/react/src/modal/portal.tsx @@ -8,7 +8,7 @@ * LICENSE file in the root directory of this source tree. */ -import { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react'; import ReactDOM from 'react-dom'; import { ReactFrameworkDelegate } from '../delegate'; @@ -21,11 +21,11 @@ export const IxOverlay = (props: { delegate: ReactFrameworkDelegate }) => { Record) => void> >({}); - const viewRefs = useRef>({}); - const [views, setViews] = useState>({}); + const viewRefs = useRef>({}); + const [views, setViews] = useState>({}); useEffect(() => { - const addOverlay = (id: string, view: any) => { + const addOverlay = (id: string, view: ReactNode) => { const _views = { ...viewRefs.current }; _views[id] = view; setViews(_views); diff --git a/packages/react/src/tests/delegate/delegate.test.tsx b/packages/react/src/tests/delegate/delegate.test.tsx index fa081d95322..2a0403a5ff6 100644 --- a/packages/react/src/tests/delegate/delegate.test.tsx +++ b/packages/react/src/tests/delegate/delegate.test.tsx @@ -23,6 +23,7 @@ const rootSelector = '[id^="ix-react-view-"]'; afterEach(() => { cleanup(); + vi.useRealTimers(); reactFrameworkDelegate.isUsingReactPortal = false; document .querySelectorAll(rootSelector) @@ -58,23 +59,44 @@ describe('ReactFrameworkDelegate', () => { }); it('waits for React to commit before resolving a delayed render', async () => { - const delegate = new ReactFrameworkDelegate(); + vi.useFakeTimers(); - const viewPromise = delegate.attachView(
Delayed content
); + let ready = false; + let resolveRender = () => {}; + const renderReady = new Promise((resolve) => { + resolveRender = () => { + ready = true; + resolve(); + }; + }); + function DelayedView() { + if (!ready) { + throw renderReady; + } + return
Delayed content
; + } - await waitFor(async () => { - const view = await viewPromise; - expect(view).toHaveTextContent('Delayed content'); + const delegate = new ReactFrameworkDelegate(); + let resolved = false; + const viewPromise = delegate.attachView().then((view) => { + resolved = true; + return view; }); + await vi.advanceTimersByTimeAsync(0); + expect(resolved).toBe(false); + + resolveRender(); + await vi.advanceTimersByTimeAsync(0); const view = await viewPromise; + expect(view).toHaveTextContent('Delayed content'); await delegate.removeView(view); }); it('rejects with a timeout and cleans up the root container when the view never commits', async () => { vi.useFakeTimers(); - function NeverCommits() { + function NeverCommits(): never { throw new Promise(() => { // never resolves, so the render never commits }); @@ -90,7 +112,6 @@ describe('ReactFrameworkDelegate', () => { await vi.advanceTimersByTimeAsync(ATTACH_VIEW_TIMEOUT_MS); await assertion; - vi.useRealTimers(); expect(document.querySelector(rootSelector)).toBeNull(); }); diff --git a/packages/react/src/tests/toasts/toast.spec.tsx b/packages/react/src/tests/toasts/toast.spec.tsx index ef7ff44c21d..0ce9d5f1f5e 100644 --- a/packages/react/src/tests/toasts/toast.spec.tsx +++ b/packages/react/src/tests/toasts/toast.spec.tsx @@ -12,7 +12,13 @@ import { afterEach, describe, expect, it } from 'vitest'; import Content from './toast'; import { iconStar } from '@siemens/ix-icons/icons'; -afterEach(cleanup); +let toastContainer: Element | null = null; + +afterEach(() => { + cleanup(); + toastContainer?.remove(); + toastContainer = null; +}); describe(`toast`, () => { it(`basic`, async () => { @@ -23,6 +29,7 @@ describe(`toast`, () => { await customElements.whenDefined('ix-toast'); await customElements.whenDefined('ix-toast-container'); + toastContainer = document.querySelector('ix-toast-container'); const toast = await screen.findByText('Foobar'); diff --git a/packages/react/src/tree/internal-tree.tsx b/packages/react/src/tree/internal-tree.tsx index d43746111b3..70627a386f0 100644 --- a/packages/react/src/tree/internal-tree.tsx +++ b/packages/react/src/tree/internal-tree.tsx @@ -26,7 +26,7 @@ type IxTreeEvents = { onContextChange: EventName>; onNodeToggled: EventName>; onNodeClicked: EventName>; - onNodeRemoved: EventName>; + onNodeRemoved: EventName>; }; const InternalIxTree: StencilReactComponent =