From 85f03816501b89bc93434c3810fbc050c7819f8b Mon Sep 17 00:00:00 2001 From: Roman Vyakhirev Date: Wed, 1 Jul 2026 14:59:34 +0200 Subject: [PATCH 1/2] fix: do not render image when the image is not available --- .../pluggableWidgets/image-web/CHANGELOG.md | 4 + .../pluggableWidgets/image-web/package.json | 3 +- .../pluggableWidgets/image-web/src/Image.tsx | 59 +----- .../src/utils/__tests__/getImageProps.spec.ts | 170 ++++++++++++++++++ .../image-web/src/utils/getImageProps.ts | 58 ++++++ pnpm-lock.yaml | 5 +- 6 files changed, 242 insertions(+), 57 deletions(-) create mode 100644 packages/pluggableWidgets/image-web/src/utils/__tests__/getImageProps.spec.ts create mode 100644 packages/pluggableWidgets/image-web/src/utils/getImageProps.ts diff --git a/packages/pluggableWidgets/image-web/CHANGELOG.md b/packages/pluggableWidgets/image-web/CHANGELOG.md index 8bd533181d..4bfb130fbc 100644 --- a/packages/pluggableWidgets/image-web/CHANGELOG.md +++ b/packages/pluggableWidgets/image-web/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Fixed + +- We fixed an issue where the Image widget would briefly render in an empty or broken state while the image data was still loading. The widget now waits until the image is ready before displaying it. + ## [1.5.1] - 2025-10-29 ### Fixed diff --git a/packages/pluggableWidgets/image-web/package.json b/packages/pluggableWidgets/image-web/package.json index 9c644692a6..bf4bcc3cac 100644 --- a/packages/pluggableWidgets/image-web/package.json +++ b/packages/pluggableWidgets/image-web/package.json @@ -54,6 +54,7 @@ "@mendix/prettier-config-web-widgets": "workspace:*", "@mendix/rollup-web-widgets": "workspace:*", "@mendix/run-e2e": "workspace:*", - "@mendix/widget-plugin-platform": "workspace:*" + "@mendix/widget-plugin-platform": "workspace:*", + "@mendix/widget-plugin-test-utils": "workspace:*" } } diff --git a/packages/pluggableWidgets/image-web/src/Image.tsx b/packages/pluggableWidgets/image-web/src/Image.tsx index d6745017b2..82cea0d4ff 100644 --- a/packages/pluggableWidgets/image-web/src/Image.tsx +++ b/packages/pluggableWidgets/image-web/src/Image.tsx @@ -1,60 +1,9 @@ import { ValueStatus } from "mendix"; import { FunctionComponent, useCallback } from "react"; import { ImageContainerProps } from "../typings/ImageProps"; -import { Image as ImageComponent, ImageType } from "./components/Image/Image"; +import { Image as ImageComponent } from "./components/Image/Image"; import { constructStyleObject } from "./utils/helpers"; - -function getImageProps({ - datasource, - imageIcon, - imageObject, - imageUrl, - defaultImageDynamic -}: ImageContainerProps): ImageType { - const fallback: ImageType = { - type: "image", - image: undefined - }; - switch (datasource) { - case "image": { - if (imageObject?.status === ValueStatus.Available) { - return { - type: "image", - image: imageObject.value.uri - }; - } else if ( - imageObject?.status === ValueStatus.Unavailable && - defaultImageDynamic?.status === ValueStatus.Available - ) { - return { - type: "image", - image: defaultImageDynamic.value.uri - }; - } - return { - type: "image", - image: undefined - }; - } - case "imageUrl": - return { - type: "image", - image: imageUrl?.status === ValueStatus.Available ? imageUrl.value : undefined - }; - case "icon": { - if (imageIcon?.status === ValueStatus.Available && imageIcon.value) { - const icon = imageIcon.value; - return { - type: icon.type, - image: icon.type === "image" ? icon.iconUrl : icon.iconClass - }; - } - return fallback; - } - default: - return fallback; - } -} +import { getImageProps } from "./utils/getImageProps"; export const Image: FunctionComponent = props => { const onClick = useCallback(() => props.onClick?.execute(), [props.onClick]); @@ -65,7 +14,7 @@ export const Image: FunctionComponent = props => { const imageStyle = { ...props.style, ...styleObject }; - return ( + return image ? ( = props => { renderAsBackground={props.datasource !== "icon" && props.isBackgroundImage} backgroundImageContent={props.children} /> - ); + ) : null; }; diff --git a/packages/pluggableWidgets/image-web/src/utils/__tests__/getImageProps.spec.ts b/packages/pluggableWidgets/image-web/src/utils/__tests__/getImageProps.spec.ts new file mode 100644 index 0000000000..70711493bf --- /dev/null +++ b/packages/pluggableWidgets/image-web/src/utils/__tests__/getImageProps.spec.ts @@ -0,0 +1,170 @@ +import { WebIcon, WebImage } from "mendix"; +import { dynamic } from "@mendix/widget-plugin-test-utils"; +import { getImageProps, GetImagePropsInput } from "../getImageProps"; + +const webImage = (uri: string): WebImage => ({ uri, name: "test.jpg" }); + +describe("getImageProps", () => { + describe('datasource: "image"', () => { + it("returns the main image URI when main image is available", () => { + const input: GetImagePropsInput = { + datasource: "image", + imageObject: dynamic.available(webImage("https://example.com/main.jpg")) + }; + expect(getImageProps(input)).toEqual({ type: "image", image: "https://example.com/main.jpg" }); + }); + + it("returns the main image URI when main image is loading (uri present)", () => { + const input: GetImagePropsInput = { + datasource: "image", + imageObject: dynamic.loading(webImage("https://example.com/main.jpg")) + }; + expect(getImageProps(input)).toEqual({ type: "image", image: "https://example.com/main.jpg" }); + }); + + it("returns undefined image when main image is loading (no uri yet)", () => { + const input: GetImagePropsInput = { + datasource: "image", + imageObject: dynamic.loading() + }; + expect(getImageProps(input)).toEqual({ type: "image", image: undefined }); + }); + + it("falls back to defaultImage when main image is unavailable and fallback is available", () => { + const input: GetImagePropsInput = { + datasource: "image", + imageObject: dynamic.unavailable(), + defaultImageDynamic: dynamic.available(webImage("https://example.com/fallback.jpg")) + }; + expect(getImageProps(input)).toEqual({ type: "image", image: "https://example.com/fallback.jpg" }); + }); + + it("falls back to defaultImage when main image is unavailable and fallback is loading", () => { + const input: GetImagePropsInput = { + datasource: "image", + imageObject: dynamic.unavailable(), + defaultImageDynamic: dynamic.loading(webImage("https://example.com/fallback.jpg")) + }; + expect(getImageProps(input)).toEqual({ type: "image", image: "https://example.com/fallback.jpg" }); + }); + + it("returns undefined image when both main image and fallback are unavailable", () => { + const input: GetImagePropsInput = { + datasource: "image", + imageObject: dynamic.unavailable(), + defaultImageDynamic: dynamic.unavailable() + }; + expect(getImageProps(input)).toEqual({ type: "image", image: undefined }); + }); + + it("returns undefined image when imageObject and defaultImageDynamic are not provided", () => { + const input: GetImagePropsInput = { datasource: "image" }; + expect(getImageProps(input)).toEqual({ type: "image", image: undefined }); + }); + + it("does not use defaultImage when main image is available", () => { + const input: GetImagePropsInput = { + datasource: "image", + imageObject: dynamic.available(webImage("https://example.com/main.jpg")), + defaultImageDynamic: dynamic.available(webImage("https://example.com/fallback.jpg")) + }; + expect(getImageProps(input)).toEqual({ type: "image", image: "https://example.com/main.jpg" }); + }); + }); + + describe('datasource: "imageUrl"', () => { + it("returns the URL when imageUrl is available", () => { + const input: GetImagePropsInput = { + datasource: "imageUrl", + imageUrl: dynamic.available("https://example.com/image.jpg") + }; + expect(getImageProps(input)).toEqual({ type: "image", image: "https://example.com/image.jpg" }); + }); + + it("returns undefined image when imageUrl is loading", () => { + const input: GetImagePropsInput = { + datasource: "imageUrl", + imageUrl: dynamic.loading() + }; + expect(getImageProps(input)).toEqual({ type: "image", image: undefined }); + }); + + it("returns undefined image when imageUrl is unavailable", () => { + const input: GetImagePropsInput = { + datasource: "imageUrl", + imageUrl: dynamic.unavailable() + }; + expect(getImageProps(input)).toEqual({ type: "image", image: undefined }); + }); + + it("returns undefined image when imageUrl is not provided", () => { + const input: GetImagePropsInput = { datasource: "imageUrl" }; + expect(getImageProps(input)).toEqual({ type: "image", image: undefined }); + }); + }); + + describe('datasource: "icon"', () => { + it("returns glyph icon class when a glyph icon is available", () => { + const glyphIcon: WebIcon = { type: "glyph", iconClass: "glyphicon-star" }; + const input: GetImagePropsInput = { + datasource: "icon", + imageIcon: dynamic.available(glyphIcon) + }; + expect(getImageProps(input)).toEqual({ type: "glyph", image: "glyphicon-star" }); + }); + + it("returns image icon URL when an image icon is available", () => { + const imageIcon: WebIcon = { type: "image", iconUrl: "https://example.com/icon.png" }; + const input: GetImagePropsInput = { + datasource: "icon", + imageIcon: dynamic.available(imageIcon) + }; + expect(getImageProps(input)).toEqual({ type: "image", image: "https://example.com/icon.png" }); + }); + + it("returns mx-icon class when a named icon is available", () => { + const namedIcon: WebIcon = { type: "icon", iconClass: "mx-icon-star" }; + const input: GetImagePropsInput = { + datasource: "icon", + imageIcon: dynamic.available(namedIcon) + }; + expect(getImageProps(input)).toEqual({ type: "icon", image: "mx-icon-star" }); + }); + + it("returns fallback when imageIcon is loading", () => { + const input: GetImagePropsInput = { + datasource: "icon", + imageIcon: dynamic.loading() + }; + expect(getImageProps(input)).toEqual({ type: "image", image: undefined }); + }); + + it("returns fallback when imageIcon is unavailable", () => { + const input: GetImagePropsInput = { + datasource: "icon", + imageIcon: dynamic.unavailable() + }; + expect(getImageProps(input)).toEqual({ type: "image", image: undefined }); + }); + + it("returns fallback when imageIcon value is undefined (WebIcon = undefined)", () => { + const input: GetImagePropsInput = { + datasource: "icon", + imageIcon: dynamic.available(undefined as WebIcon) + }; + expect(getImageProps(input)).toEqual({ type: "image", image: undefined }); + }); + + it("returns fallback when imageIcon is not provided", () => { + const input: GetImagePropsInput = { datasource: "icon" }; + expect(getImageProps(input)).toEqual({ type: "image", image: undefined }); + }); + }); + + describe("unknown datasource", () => { + it("returns fallback for an unrecognised datasource value", () => { + const input = { datasource: "unknown" as GetImagePropsInput["datasource"] }; + expect(getImageProps(input)).toEqual({ type: "image", image: undefined }); + }); + }); +}); diff --git a/packages/pluggableWidgets/image-web/src/utils/getImageProps.ts b/packages/pluggableWidgets/image-web/src/utils/getImageProps.ts new file mode 100644 index 0000000000..59734686a2 --- /dev/null +++ b/packages/pluggableWidgets/image-web/src/utils/getImageProps.ts @@ -0,0 +1,58 @@ +import { ValueStatus } from "mendix"; +import { ImageContainerProps } from "../../typings/ImageProps"; +import { ImageType } from "../components/Image/Image"; + +export type GetImagePropsInput = Pick< + ImageContainerProps, + "datasource" | "imageIcon" | "imageObject" | "imageUrl" | "defaultImageDynamic" +>; + +const fallback: ImageType = { type: "image", image: undefined }; + +export function getImageProps({ + datasource, + imageIcon, + imageObject, + imageUrl, + defaultImageDynamic: defaultImage +}: GetImagePropsInput): ImageType { + switch (datasource) { + case "image": { + // if main image is available or loading + if (imageObject?.status === ValueStatus.Available || imageObject?.status === ValueStatus.Loading) { + return { + type: "image", + image: imageObject?.value?.uri + }; + } + + // if main image is not available, but fallback is available or loading + if (defaultImage?.status === ValueStatus.Available || defaultImage?.status === ValueStatus.Loading) { + return { + type: "image", + image: defaultImage?.value?.uri + }; + } + + // if main image and fallback are not available + return { type: "image", image: undefined }; + } + case "imageUrl": + return { + type: "image", + image: imageUrl?.status === ValueStatus.Available ? imageUrl.value : undefined + }; + case "icon": { + if (imageIcon?.status === ValueStatus.Available && imageIcon.value) { + const icon = imageIcon.value; + return { + type: icon.type, + image: icon.type === "image" ? icon.iconUrl : icon.iconClass + }; + } + return fallback; + } + default: + return fallback; + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40105448c2..0fb148f93a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1721,7 +1721,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.11.0 - version: 11.11.0(patch_hash=036a1e3d1a57e7418725babb71e5eef5220ae90fc481ad7e04fa7e8901b25801)(@jest/transform@30.3.0)(@jest/types@30.4.1)(@swc/core@1.15.41)(@types/babel__core@7.20.5)(@types/node@24.12.4)(canvas@3.2.3)(eslint@9.39.4(jiti@2.6.1))(jest-util@30.4.1)(prettier@3.8.4)(react-dom@18.3.1(react@18.3.1))(react-native@0.86.0(@babel/core@7.29.7)(@types/react@19.2.17)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.11.0(patch_hash=036a1e3d1a57e7418725babb71e5eef5220ae90fc481ad7e04fa7e8901b25801)(@jest/transform@30.3.0)(@jest/types@30.4.1)(@swc/core@1.15.41)(@types/babel__core@7.20.5)(@types/node@24.12.4)(canvas@3.2.3)(eslint@9.39.4(jiti@2.6.1))(jest-util@30.4.1)(picomatch@4.0.4)(prettier@3.8.4)(react-dom@18.3.1(react@18.3.1))(react-native@0.86.0(@babel/core@7.29.7)(@types/react@19.2.17)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -1734,6 +1734,9 @@ importers: '@mendix/widget-plugin-platform': specifier: workspace:* version: link:../../shared/widget-plugin-platform + '@mendix/widget-plugin-test-utils': + specifier: workspace:* + version: link:../../shared/widget-plugin-test-utils packages/pluggableWidgets/line-chart-web: dependencies: From 704a36944e1c5eb0debc93e2c5e9e36c2c134ece Mon Sep 17 00:00:00 2001 From: Roman Vyakhirev Date: Fri, 10 Jul 2026 14:05:27 +0200 Subject: [PATCH 2/2] chore: apply review suggestions --- .../src/utils/__tests__/getImageProps.spec.ts | 10 +++++++++- .../image-web/src/utils/getImageProps.ts | 8 ++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/pluggableWidgets/image-web/src/utils/__tests__/getImageProps.spec.ts b/packages/pluggableWidgets/image-web/src/utils/__tests__/getImageProps.spec.ts index 70711493bf..fd2c2a0c8d 100644 --- a/packages/pluggableWidgets/image-web/src/utils/__tests__/getImageProps.spec.ts +++ b/packages/pluggableWidgets/image-web/src/utils/__tests__/getImageProps.spec.ts @@ -81,7 +81,15 @@ describe("getImageProps", () => { expect(getImageProps(input)).toEqual({ type: "image", image: "https://example.com/image.jpg" }); }); - it("returns undefined image when imageUrl is loading", () => { + it("returns stale URL when imageUrl is loading with a cached value", () => { + const input: GetImagePropsInput = { + datasource: "imageUrl", + imageUrl: dynamic.loading("https://example.com/image.jpg") + }; + expect(getImageProps(input)).toEqual({ type: "image", image: "https://example.com/image.jpg" }); + }); + + it("returns undefined image when imageUrl is loading with no cached value", () => { const input: GetImagePropsInput = { datasource: "imageUrl", imageUrl: dynamic.loading() diff --git a/packages/pluggableWidgets/image-web/src/utils/getImageProps.ts b/packages/pluggableWidgets/image-web/src/utils/getImageProps.ts index 59734686a2..b67d246fcc 100644 --- a/packages/pluggableWidgets/image-web/src/utils/getImageProps.ts +++ b/packages/pluggableWidgets/image-web/src/utils/getImageProps.ts @@ -18,7 +18,6 @@ export function getImageProps({ }: GetImagePropsInput): ImageType { switch (datasource) { case "image": { - // if main image is available or loading if (imageObject?.status === ValueStatus.Available || imageObject?.status === ValueStatus.Loading) { return { type: "image", @@ -26,7 +25,6 @@ export function getImageProps({ }; } - // if main image is not available, but fallback is available or loading if (defaultImage?.status === ValueStatus.Available || defaultImage?.status === ValueStatus.Loading) { return { type: "image", @@ -34,13 +32,15 @@ export function getImageProps({ }; } - // if main image and fallback are not available return { type: "image", image: undefined }; } case "imageUrl": return { type: "image", - image: imageUrl?.status === ValueStatus.Available ? imageUrl.value : undefined + image: + imageUrl?.status === ValueStatus.Available || imageUrl?.status === ValueStatus.Loading + ? imageUrl.value + : undefined }; case "icon": { if (imageIcon?.status === ValueStatus.Available && imageIcon.value) {