Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/clean-react-modal-roots.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/lazy-vue-tabs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@siemens/ix-vue': minor
---

Expose `IxTabSet` and `IxTabPanel` wrappers with lazy rendering of inactive tab panel content.
5 changes: 5 additions & 0 deletions .changeset/type-safe-react-callbacks.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/type-safe-vue-close-modal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@siemens/ix-vue': patch
---

Use `unknown` as the default reason type for the exported `closeModal` helper.
1 change: 1 addition & 0 deletions BREAKING_CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions BREAKING_CHANGES/v6.md
Original file line number Diff line number Diff line change
@@ -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<unknown>`.

Specify generic result types where needed and narrow callback values before
accessing their properties.
7 changes: 6 additions & 1 deletion packages/ionic-test-app/eslint.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
121 changes: 106 additions & 15 deletions packages/react/src/delegate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,36 +8,103 @@
*/
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<string, ReactDOMClient.Root> = {};
const mountedDomViews = new WeakSet<Element>();

function CommitSignal({ onCommit }: { onCommit: () => void }) {
useLayoutEffect(onCommit, [onCommit]);
return null;
}

async function fallbackRootDom(id: string, view: React.ReactNode) {
return new Promise((resolve) => {
async function fallbackRootDom(id: string, view: ReactNode): Promise<Element> {
return new Promise<Element>((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);
}
});
}

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');
}

Comment thread
nuke-ellington marked this conversation as resolved.
const id = parent.id;
if (id in mountedRootNodes) {
mountedRootNodes[id].unmount();
Expand All @@ -47,7 +114,7 @@ async function fallbackRemoveViewFromRootDom(view: any) {
}

export class ReactFrameworkDelegate implements FrameworkDelegate {
attachViewToPortal?: (id: string, view: any) => Promise<Element>;
attachViewToPortal?: (id: string, view: ReactNode) => Promise<Element>;
removeViewFromPortal?: (id: string) => void;

resolvePortalInitPromise: (() => void) | undefined;
Expand All @@ -60,29 +127,53 @@ export class ReactFrameworkDelegate implements FrameworkDelegate {
);
}

async attachView(view: any): Promise<any> {
async attachView<R = HTMLElement>(view: ReactNode | HTMLElement): Promise<R> {
if (view instanceof HTMLElement) {
document.body.appendChild(view);
mountedDomViews.add(view);
return view as R;
}

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<void> {
async removeView(view: unknown): Promise<void> {
if (!(view instanceof Element)) {
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);
}

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);
}
Expand Down
4 changes: 2 additions & 2 deletions packages/react/src/modal/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -60,7 +60,7 @@ export function dismissModal(modalInstance: IxModalInstance) {
}
}

export function closeModal<T = any>(
export function closeModal<T = unknown>(
modalInstance: IxModalInstance,
reason?: T
) {
Expand Down
4 changes: 2 additions & 2 deletions packages/react/src/modal/modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ import React, { useImperativeHandle, useRef } from 'react';
import { IxModal } from '../components';

export interface ModalRef {
close: <T = any>(result: T) => void;
dismiss: <T = any>(result?: T) => void;
close: <T = unknown>(result: T) => void;
dismiss: <T = unknown>(result?: T) => void;
modalElement: HTMLIxModalElement | null;
}

Expand Down
8 changes: 4 additions & 4 deletions packages/react/src/modal/portal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -21,11 +21,11 @@ export const IxOverlay = (props: { delegate: ReactFrameworkDelegate }) => {
Record<string, (value: Element | PromiseLike<Element>) => void>
>({});

const viewRefs = useRef<Record<string, any>>({});
const [views, setViews] = useState<Record<string, any>>({});
const viewRefs = useRef<Record<string, ReactNode>>({});
const [views, setViews] = useState<Record<string, ReactNode>>({});

useEffect(() => {
const addOverlay = (id: string, view: any) => {
const addOverlay = (id: string, view: ReactNode) => {
const _views = { ...viewRefs.current };
_views[id] = view;
setViews(_views);
Expand Down
Loading
Loading