diff --git a/src/Hook/ReduxIntegratedFieldWidgetsHooks.php b/src/Hook/ReduxIntegratedFieldWidgetsHooks.php
index 5c85b996779b1a2dbc2730218ccaac72bab220e3..204e4aec06b52fc2cb0208c77652e0dcb39d0fb3 100644
--- a/src/Hook/ReduxIntegratedFieldWidgetsHooks.php
+++ b/src/Hook/ReduxIntegratedFieldWidgetsHooks.php
@@ -111,6 +111,13 @@ class ReduxIntegratedFieldWidgetsHooks implements TrustedCallbackInterface {
         '.media-library-wrapper',
         '.ui-dialog',
       ]);
+
+      // Most hidden fields are read only. Add an attribute that allows it to be
+      // updated and tracked in Redux form state.
+      if (isset($form['selection'][0]['target_id'])) {
+        $form['selection'][0]['target_id']['#attributes']['data-track-hidden-value'] = 'true';
+      }
+
     }
     // Use an XB-specific media library opener, because the default opener assumes
     // the media library is opened for a field widget of a field instance on the
diff --git a/tests/modules/xb_test_e2e_code_components/config/install/experience_builder.js_component.xb_test_e2e_code_components_optional_image.yml b/tests/modules/xb_test_e2e_code_components/config/install/experience_builder.js_component.xb_test_e2e_code_components_optional_image.yml
new file mode 100644
index 0000000000000000000000000000000000000000..92aa3e3191fefa4e71f4d22e540aff6605c9bb22
--- /dev/null
+++ b/tests/modules/xb_test_e2e_code_components/config/install/experience_builder.js_component.xb_test_e2e_code_components_optional_image.yml
@@ -0,0 +1,31 @@
+langcode: en
+status: true
+dependencies: {  }
+machineName: xb_test_e2e_code_components_optional_image
+name: CC Optional Image
+block_override: null
+required: {  }
+props:
+  image:
+    title: image
+    type: object
+    examples:
+      -
+        src: 'https://placehold.co/1200x900@2x.png'
+        width: 1200
+        height: 900
+        alt: 'Example image placeholder'
+    $ref: 'json-schema-definitions://experience_builder.module/image'
+  text:
+    title: text
+    type: string
+    examples:
+      - 'Some text'
+slots: { }
+js:
+  original: "const Image = ({  image,  text,}) => {  return ( <div>   <img {...image} \/>   <p>{text}<\/p> <\/div>  );};\n\nexport default Image;"
+  compiled: "function _define_property(obj, key, value) { if (key in obj) {     Object.defineProperty(obj, key, {         value: value,         enumerable: true,         configurable: true,         writable: true     }); } else {     obj[key] = value; } return obj;}function _object_spread(target) { for(var i = 1; i < arguments.length; i++){     var source = arguments[i] != null ? arguments[i] : {};     var ownKeys = Object.keys(source);     if (typeof Object.getOwnPropertySymbols === \"function\") {         ownKeys = ownKeys.concat(Object.getOwnPropertySymbols(source).filter(function(sym) {             return Object.getOwnPropertyDescriptor(source, sym).enumerable;         }));     }     ownKeys.forEach(function(key) {         _define_property(target, key, source[key]);     }); } return target;}import { jsx as _jsx, jsxs as _jsxs } from \"react\/jsx-runtime\";const Image = ({ image, text })=>{ return \/*#__PURE__*\/ _jsxs(\"div\", {     children: [         \/*#__PURE__*\/ _jsx(\"img\", _object_spread({}, image)),         \/*#__PURE__*\/ _jsx(\"p\", {             children: text         })     ] });};export default Image;"
+css:
+  original: ''
+  compiled: ''
+
diff --git a/tests/modules/xb_test_e2e_code_components/config/install/experience_builder.js_component.xb_test_e2e_code_components_req_image.yml b/tests/modules/xb_test_e2e_code_components/config/install/experience_builder.js_component.xb_test_e2e_code_components_req_image.yml
new file mode 100644
index 0000000000000000000000000000000000000000..cb4fbff5539798985e5928569ba04a565b656e63
--- /dev/null
+++ b/tests/modules/xb_test_e2e_code_components/config/install/experience_builder.js_component.xb_test_e2e_code_components_req_image.yml
@@ -0,0 +1,32 @@
+langcode: en
+status: true
+dependencies: {  }
+machineName: xb_test_e2e_code_components_req_image
+name: CC Required Image
+block_override: null
+required:
+  - image
+props:
+  image:
+    title: image
+    type: object
+    examples:
+      -
+        src: 'https://placehold.co/1200x900@2x.png'
+        width: 1200
+        height: 900
+        alt: 'Example image placeholder'
+    $ref: 'json-schema-definitions://experience_builder.module/image'
+  text:
+    title: text
+    type: string
+    examples:
+      - 'Some Text'
+slots: { }
+js:
+  original: "const Image = ({\n        image,\n        text,\n      }) => {\n        return ( <div>   <img {...image} \/>   <p>{text}<\/p> <\/div>\n        );\n      };\n\n\n      export default Image;"
+  compiled: "function _define_property(obj, key, value) { if (key in obj) {     Object.defineProperty(obj, key, {         value: value,         enumerable: true,         configurable: true,         writable: true     }); } else {     obj[key] = value; } return obj;\n      }\n      function _object_spread(target) { for(var i = 1; i < arguments.length; i++){     var source = arguments[i] != null ? arguments[i] : {};     var ownKeys = Object.keys(source);     if (typeof Object.getOwnPropertySymbols === \"function\") {         ownKeys = ownKeys.concat(Object.getOwnPropertySymbols(source).filter(function(sym) {             return Object.getOwnPropertyDescriptor(source, sym).enumerable;         }));     }     ownKeys.forEach(function(key) {         _define_property(target, key, source[key]);     }); } return target;\n      }\n      import { jsx as _jsx, jsxs as _jsxs } from \"react\/jsx-runtime\";\n      const Image = ({ image, text })=>{ return \/*#__PURE__*\/ _jsxs(\"div\", {     children: [         \/*#__PURE__*\/ _jsx(\"img\", _object_spread({}, image)),         \/*#__PURE__*\/ _jsx(\"p\", {             children: text         })     ] });\n      };\n      export default Image;"
+css:
+  original: ''
+  compiled: ''
+
diff --git a/tests/modules/xb_test_e2e_code_components/xb_test_e2e_code_components.info.yml b/tests/modules/xb_test_e2e_code_components/xb_test_e2e_code_components.info.yml
new file mode 100644
index 0000000000000000000000000000000000000000..21e7b93057d2c96bacf163cb1748464818743b2e
--- /dev/null
+++ b/tests/modules/xb_test_e2e_code_components/xb_test_e2e_code_components.info.yml
@@ -0,0 +1,6 @@
+name: XB test e2e code components
+description: 'Code components for e2e testing only - not for use by Kernel tests etc.'
+package: Testing
+type: module
+dependencies:
+  - experience_builder:experience_builder
diff --git a/ui/cypress.config.js b/ui/cypress.config.js
index c1c6146fe6651d3e593ca66c67a0e1f436f8345e..af6b707280e46b73b1bc4fb2bdfdf561315e5812 100644
--- a/ui/cypress.config.js
+++ b/ui/cypress.config.js
@@ -34,7 +34,7 @@ export default defineConfig({
   chromeWebSecurity: false,
   defaultBrowser: process.env.DRUPAL_TEST_DEFAULT_BROWSER || 'chrome',
   watchForFileChanges: false,
-  retries: 0,
+  retries: { openMode: 0, runMode: 3 },
   env: {
     baseUrl: process.env.BASE_URL,
     dbUrl: process.env.DB_URL,
diff --git a/ui/src/components/form/components/Hidden.tsx b/ui/src/components/form/components/Hidden.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..a522a1bc089e9047fdf1d4f119ef37fd0bcf94f6
--- /dev/null
+++ b/ui/src/components/form/components/Hidden.tsx
@@ -0,0 +1,92 @@
+import { a2p } from '@/local_packages/utils';
+import type { Attributes } from '@/types/DrupalAttribute';
+import { useEffect, useRef, useState } from 'react';
+import useMutationObserver from '@/hooks/useMutationObserver';
+import { VALUE_THAT_MEANS_REMOVE } from '@/utils/function-utils';
+
+const Hidden = ({ attributes }: { attributes?: Attributes }) => {
+  const [value, setValue] = useState(attributes?.value || '');
+  const ref = useRef<HTMLInputElement | null>(null);
+
+  useEffect(() => {
+    // Add a mutation observer to the form element this input belongs to. This
+    // allows us to detect when the input is removed from the DOM, which allows
+    // us to communicate that removal to the Redux store.
+    // Because the observer is attached to a parent of the ref, it is added here
+    // instead of using the `useMutationObserver` hook as placing it in the
+    // useEffect ensures the full form has been rendered already.
+    let observer: MutationObserver | null = null;
+    if (ref.current) {
+      const name = ref.current.name;
+      observer = new MutationObserver((mutations) => {
+        mutations.forEach((mutation) => {
+          const removedNode = mutation.removedNodes[0] as Element | null;
+          // Check if  the removed element is the input managed by this
+          // component.
+          if (
+            removedNode &&
+            'querySelector' in removedNode &&
+            removedNode.querySelector(`[name="${name}"]`) &&
+            ref.current
+          ) {
+            ref.current.value = '';
+            // Call the onChange listener so the Redux store is updated.
+            if (
+              attributes?.onChange &&
+              typeof attributes.onChange === 'function'
+            ) {
+              const event = new Event('change');
+              ref.current!.value = VALUE_THAT_MEANS_REMOVE;
+              Object.defineProperty(event, 'target', {
+                writable: false,
+                value: ref.current,
+              });
+              attributes.onChange(event);
+            }
+          }
+        });
+      });
+
+      if (
+        ref.current &&
+        'closest' in ref.current &&
+        ref.current.closest('form') &&
+        observer instanceof MutationObserver
+      ) {
+        observer.observe(ref.current.closest('form')!, {
+          childList: true,
+          subtree: true,
+        });
+      }
+    }
+    return () => {
+      if (observer instanceof MutationObserver) {
+        observer.disconnect();
+      }
+    };
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);
+
+  // Hidden field values might be updated by AJAX requests and those value
+  // changes should persist on rerender instead of falling back to the initial
+  // value in `attributes`. A Mutation Observer is used to monitor value changes
+  // and keeps track of them in state.
+  useMutationObserver(
+    ref,
+    (mutations) => {
+      mutations.forEach((record: MutationRecord) => {
+        if (record?.attributeName === 'value') {
+          if (record.target instanceof HTMLElement) {
+            const newValue = record.target.getAttribute(record.attributeName);
+            setValue(`${newValue}`);
+          }
+        }
+      });
+    },
+    { attributes: true },
+  );
+
+  return <input ref={ref} {...a2p(attributes || {})} value={value} />;
+};
+
+export default Hidden;
diff --git a/ui/src/components/form/components/drupal/DrupalInput.tsx b/ui/src/components/form/components/drupal/DrupalInput.tsx
index 149fd743990ec374b9bd52ca00cb94b597e6228e..5fc1f06365705cc6f6835d4c5e00d8c40b6be8c7 100644
--- a/ui/src/components/form/components/drupal/DrupalInput.tsx
+++ b/ui/src/components/form/components/drupal/DrupalInput.tsx
@@ -5,6 +5,7 @@ import { a2p } from '@/local_packages/utils.js';
 import InputBehaviors from '@/components/form/inputBehaviors';
 import TextField from '@/components/form/components/TextField';
 import { DrupalRadioItem } from '@/components/form/components/drupal/DrupalRadio';
+import Hidden from '@/components/form/components/Hidden';
 import Checkbox from '@/components/form/components/Checkbox';
 import type { Attributes } from '@/types/DrupalAttribute';
 import TextFieldAutocomplete from '@/components/form/components/TextFieldAutocomplete';
@@ -60,6 +61,9 @@ const DrupalInput = ({
       return <DrupalRadioItem attributes={attributes} />;
     case 'hidden':
     case 'submit':
+      if (attributes['data-track-hidden-value']) {
+        return <Hidden attributes={attributes} />;
+      }
       // The a2p() process converts 'value to 'defaultValue', which is typically
       // what React wants. Explicitly set the value on submit inputs since that
       // is the text it displays.
diff --git a/ui/src/components/form/formUtil.ts b/ui/src/components/form/formUtil.ts
index a1f6ce1958d0d13536392ccf540af46bc7b1012d..bc2d025d1bffc9fdd0135f4281a31c96b6e7fac5 100644
--- a/ui/src/components/form/formUtil.ts
+++ b/ui/src/components/form/formUtil.ts
@@ -300,7 +300,9 @@ export const formStateToObject = (
   });
   const parsed = qs.parse(params.toString());
   if (isParsedQ(parsed.xb_component_props)) {
-    return parsed.xb_component_props[componentId] as PropsValues;
+    if (parsed.xb_component_props[componentId]) {
+      return parsed.xb_component_props[componentId] as PropsValues;
+    }
   }
   return {};
 };
diff --git a/ui/src/components/form/inputBehaviors.tsx b/ui/src/components/form/inputBehaviors.tsx
index f993cb4cc9bb26ff8f5bbf15b4b4882ddda73a74..25f98340de3a34d4845708e2fff92ac5a054f703 100644
--- a/ui/src/components/form/inputBehaviors.tsx
+++ b/ui/src/components/form/inputBehaviors.tsx
@@ -20,7 +20,7 @@ import {
   selectLayout,
   selectModel,
 } from '@/features/layout/layoutModelSlice';
-import { parseValue } from '@/utils/function-utils';
+import { parseValue, flaggedForRemoval } from '@/utils/function-utils';
 import { debounce } from 'lodash';
 import { useGetComponentsQuery } from '@/services/componentAndLayout';
 import { findComponentByUuid } from '@/features/layout/layoutUtils';
@@ -186,10 +186,12 @@ const InputBehaviorsCommon = ({
     [],
   );
 
-  // Don't track the value of hidden fields except for form_build_id.
+  // Don't track the value of hidden fields except for form_build_id or ones
+  // with the 'data-track-hidden-value' attribute set.
   if (
     ['hidden', 'submit'].includes(elementType as string) &&
-    fieldName !== 'form_build_id'
+    fieldName !== 'form_build_id' &&
+    !attributes['data-track-hidden-value']
   ) {
     attributes.readOnly = '';
   } else if (!attributes['data-drupal-uncontrolled']) {
@@ -343,6 +345,25 @@ const InputBehaviorsComponentPropsForm = (
     //    in onQueryStarted in preview.ts
     // @see \Drupal\Core\Field\WidgetInterface::massageFormValues()
     const resolved = { ...selectedModel.resolved, ...values };
+
+    // Check the object for any values that are flagged for removal. Note that
+    // removal flagging is not necessary for all prop types. It is used for
+    // props with complex prop shapes where the empty-indicating value is nested
+    // with the structure.
+    Object.keys(values).forEach((prop) => {
+      if (flaggedForRemoval(values[prop]) && component?.propSources?.[prop]) {
+        if (!component.propSources[prop]?.required) {
+          if (isEvaluatedComponentModel(selectedModel)) {
+            // The source value can also be updated to empty when permitted.
+            if (!Object.isFrozen(selectedModel.source[prop])) {
+              selectedModel.source[prop].value = [];
+            }
+          }
+        }
+        resolved[prop] = [];
+      }
+    });
+
     if (isEvaluatedComponentModel(selectedModel) && component) {
       patchComponent({
         componentInstanceUuid: selectedComponent,
diff --git a/ui/src/features/layout/layoutModelSlice.ts b/ui/src/features/layout/layoutModelSlice.ts
index dee6177f45c73d54808b52d5aee8ecf9ffcd2ffa..eb4f6ecb7e0e37a2dfef94df19695f0a7b2e87b6 100644
--- a/ui/src/features/layout/layoutModelSlice.ts
+++ b/ui/src/features/layout/layoutModelSlice.ts
@@ -130,6 +130,7 @@ type AnyValue = string | boolean | [] | number | {} | null;
 // @see \Drupal\experience_builder\PropSource\PropSource::parse()
 interface BasePropSource {
   sourceType: string;
+  value?: any;
 }
 // @see \Drupal\experience_builder\PropSource\DynamicPropSource
 export interface DynamicPropSource extends BasePropSource {
diff --git a/ui/src/hooks/useMutationObserver.ts b/ui/src/hooks/useMutationObserver.ts
new file mode 100644
index 0000000000000000000000000000000000000000..dd3e3132f0fe2d5ea8e0e55672f8326609386dca
--- /dev/null
+++ b/ui/src/hooks/useMutationObserver.ts
@@ -0,0 +1,31 @@
+import { useEffect } from 'react';
+import type { MutableRefObject } from 'react';
+interface ObserverOptions {
+  attributes?: boolean;
+  characterData?: boolean;
+  childList?: boolean;
+  subtree?: boolean;
+}
+
+// This is largely pulled from
+// https://www.30secondsofcode.org/react/s/use-mutation-observer/
+const useMutationObserver = (
+  ref: MutableRefObject<undefined | null | any>,
+  callback: MutationCallback,
+  options: ObserverOptions = {
+    attributes: true,
+    characterData: true,
+    childList: true,
+    subtree: true,
+  },
+) => {
+  useEffect(() => {
+    if (ref.current) {
+      const observer = new MutationObserver(callback);
+      observer.observe(ref.current, options);
+      return () => observer.disconnect();
+    }
+  }, [callback, options, ref]);
+};
+
+export default useMutationObserver;
diff --git a/ui/src/types/Component.ts b/ui/src/types/Component.ts
index 2aa0e8781569f461bda8b1e9d2783a8ade5e029d..7dbfb297d8f333680a0e2dce236b4426651b6481 100644
--- a/ui/src/types/Component.ts
+++ b/ui/src/types/Component.ts
@@ -35,6 +35,7 @@ interface BaseComponent {
   css: string;
   js_header: string;
   js_footer: string;
+  propSources: FieldData;
 }
 
 export type libraryTypes =
diff --git a/ui/src/utils/function-utils.ts b/ui/src/utils/function-utils.ts
index 7102422306ca76a6d56f62748048dfc40b895739..d962582fee61aee8bd614ac8df06f02e01c0665a 100644
--- a/ui/src/utils/function-utils.ts
+++ b/ui/src/utils/function-utils.ts
@@ -33,6 +33,40 @@ export function isConsecutive(sortedIndexes: number[]): boolean {
   return true;
 }
 
+// Some prop shapes do not have a means of representing an empty value, so they
+// can't simply have their value replaced when removed. This special string
+// flags items for removal that can't be replaced with an empty value.
+export const VALUE_THAT_MEANS_REMOVE = '$%^&*JUST_REMOVE';
+
+/**
+ * Checks if a value is flagged for removal.
+ *
+ *  @param {any} value
+ *    The value to check.
+ */
+export function flaggedForRemoval(value: any): boolean {
+  // This will return true if any value within the structure equals
+  // VALUE_THAT_MEANS_REMOVE.
+
+  if (value === null || value === undefined) {
+    return false;
+  }
+
+  if (typeof value === 'string') {
+    return value === VALUE_THAT_MEANS_REMOVE;
+  }
+
+  if (Array.isArray(value)) {
+    return value.some((item) => flaggedForRemoval(item));
+  }
+
+  if (typeof value === 'object') {
+    return Object.values(value).some((item) => flaggedForRemoval(item));
+  }
+
+  return false;
+}
+
 export function parseValue(
   value: any,
   element: HTMLInputElement | HTMLSelectElement,
diff --git a/ui/tests/e2e/media-library.cy.js b/ui/tests/e2e/media-library.cy.js
index 8e82c8165f738890df4f3e72fcb2aa22a26977ab..410241e9df26fd11866bdae415234d1989a3cc5b 100644
--- a/ui/tests/e2e/media-library.cy.js
+++ b/ui/tests/e2e/media-library.cy.js
@@ -19,8 +19,14 @@ const iterations = [
   },
 ];
 
-const testMediaLibraryInComponentInstanceForm = (cy) => {
+const testMediaLibraryInComponentInstanceForm = (
+  cy,
+  entityType = 'xb_page',
+) => {
   cy.get('div[role="dialog"]').should('exist');
+  cy.findByLabelText('Select The bones are their money').should(
+    'not.be.checked',
+  );
   cy.findByLabelText('Select The bones are their money').check();
   cy.get('button:contains("Insert selected")').click();
   cy.get('div[role="dialog"]').should('not.exist');
@@ -39,25 +45,49 @@ const testMediaLibraryInComponentInstanceForm = (cy) => {
   cy.clickComponentInPreview('Image', 1);
   cy.clickComponentInPreview('Image');
 
+  // The image location in the preview is different depending on the entity
+  // type.
   cy.get('[data-testid*="xb-component-form-"]').as('inputForm');
-  iterations.forEach((step) => {
+  cy.intercept('PATCH', '**/xb/api/v0/form/component-instance/**').as('patch');
+
+  iterations.forEach((step, index) => {
+    cy.get('@inputForm').recordFormBuildId();
+    const priorAlt =
+      index % 2 === 0 ? iterations[1].expectedAlt : iterations[0].expectedAlt;
+    const defaultPlaceholder =
+      entityType === 'xb_page'
+        ? `[id^="block-"] > img[alt="${priorAlt}"]:first-of-type`
+        : `img[alt="${priorAlt}"][data-xb-uuid="static-image-udf7d"]`;
+    cy.log(
+      `Iteration ${index + 1}: start ${index % 2 === 0 ? iterations[1].expectedAlt : iterations[0].expectedAlt}`,
+    );
     cy.get('[class*="contextualPanel"]').should('exist');
     cy.get('div[role="dialog"]').should('not.exist');
-    cy.get('@inputForm').recordFormBuildId();
-    cy.get('[class*="contextualPanel"]')
-      .findByLabelText(step.removeText)
-      .click();
-    cy.get('@inputForm').shouldHaveUpdatedFormBuildId();
-    cy.get(
-      '[class*="contextualPanel"] .js-media-library-open-button[data-once="drupal-ajax"]',
-    )
-      .first()
-      .click();
+    const removeIt = `[class*="contextualPanel"] .js-media-library-selection  [aria-label="${step.removeText}"][data-once="drupal-ajax"]`;
+    cy.get(removeIt).click({ force: true });
+
+    cy.log(
+      `Confirm removing a required image in step ${index + 1} results in the example appearing in the preview.`,
+    );
+
+    // The prior image should still be there because the prop is required.
+    cy.waitForElementInIframe(defaultPlaceholder);
+
+    // Waiting for the build id does not work - it does not update.
+    // Waiting for the preview (the last request after clicking remove) does not
+    // appear to work reliably either. Hence, the fixed wait. Presumably there is
+    // something that can be waited on, but it is not clear what.
+    // eslint-disable-next-line cypress/no-unnecessary-waiting
+    cy.wait(1000);
+    const addIt = `[class*="contextualPanel"] .js-media-library-widget .js-media-library-open-button[data-once="drupal-ajax"]`;
+    cy.get(addIt).first().click({ force: true });
+
     cy.get('div[role="dialog"]').should('exist');
     cy.findByLabelText(step.selectNewText).check();
-    cy.get('button:contains("Insert selected")').click();
+    cy.get('button:contains("Insert selected")').realClick({ force: true });
+    cy.wait('@patch');
     cy.get('div[role="dialog"]').should('not.exist');
-    cy.get('@inputForm').shouldHaveUpdatedFormBuildId();
+    cy.get('@inputForm').shouldHaveUpdatedFormBuildId({ timeout: 11000 });
     cy.get(
       `[class*="contextualPanel"] input[aria-label="${step.removeAriaLabel}"]`,
     ).should('exist');
@@ -92,6 +122,7 @@ const testMediaLibraryInEntityForm = (cy, loadOptions = {}, title) => {
 
   // Perform media operations.
   iterations.forEach((step, ix) => {
+    cy.log(`Iteration ${ix + 1}: start`);
     cy.findByRole('dialog').should('not.exist');
     cy.get('@entityForm').findByRole(step.expectedAlt).should('not.exist');
     if (ix > 0) {
@@ -104,6 +135,7 @@ const testMediaLibraryInEntityForm = (cy, loadOptions = {}, title) => {
       cy.wait('@updatePreview');
       cy.findByLabelText('Loading Preview').should('not.exist');
       cy.get('@entityForm').shouldHaveUpdatedFormBuildId();
+      cy.log(`Iteration ${ix + 1}: ${step.removeText} complete`);
     }
     cy.get('@entityForm')
       .findByRole('button', { name: 'Add media', timeout: 10000 })
@@ -129,6 +161,7 @@ const testMediaLibraryInEntityForm = (cy, loadOptions = {}, title) => {
       .findByRole('button', { name: step.removeAriaLabel })
       .should('exist');
     cy.get('@entityForm').shouldHaveUpdatedFormBuildId();
+    cy.log(`Iteration ${ix + 1}: Adding ${step.expectedAlt} complete`);
   });
 
   cy.publishAllPendingChanges(title);
@@ -148,7 +181,7 @@ const testMediaLibraryInEntityForm = (cy, loadOptions = {}, title) => {
 
 describe('Media Library', () => {
   before(() => {
-    cy.drupalXbInstall();
+    cy.drupalXbInstall(['xb_test_sdc', 'xb_test_e2e_code_components']);
   });
 
   beforeEach(() => {
@@ -192,7 +225,7 @@ describe('Media Library', () => {
     )
       .first()
       .click();
-    testMediaLibraryInComponentInstanceForm(cy);
+    testMediaLibraryInComponentInstanceForm(cy, 'article');
   });
 
   it(
@@ -215,7 +248,7 @@ describe('Media Library', () => {
       )
         .first()
         .click();
-      testMediaLibraryInComponentInstanceForm(cy);
+      testMediaLibraryInComponentInstanceForm(cy, 'xb_page');
     },
   );
 
@@ -238,4 +271,165 @@ describe('Media Library', () => {
       );
     },
   );
+
+  it('Can remove an optional image no example and there is no image in the preview', () => {
+    cy.drupalLogin('xbUser', 'xbUser');
+    cy.loadURLandWaitForXBLoaded({ url: 'xb/node/2' });
+    cy.openLibraryPanel();
+    cy.get(
+      '[data-xb-component-id="sdc.xb_test_sdc.image-optional-without-example"]',
+    ).realClick();
+    cy.waitForElementNotInIframe('.layout-content img');
+    cy.get(
+      '[class*="contextualPanel"] .js-media-library-open-button[data-once="drupal-ajax"]',
+    )
+      .first()
+      .click();
+    cy.get('div[role="dialog"]').should('exist');
+    cy.findByLabelText('Select The bones are their money').check();
+    cy.get('button:contains("Insert selected")').click();
+    cy.get('div[role="dialog"]').should('not.exist');
+    cy.waitForElementInIframe('img[alt="The bones equal dollars"]');
+    cy.get('[class*="contextualPanel"]')
+      .findByLabelText('Remove The bones are their money')
+      .click();
+
+    // Confirms the removed optional image prop is not rendered at all, vs the
+    // example/default value reappearing.
+    cy.waitForElementNotInIframe('.layout-content img');
+  });
+
+  it('Can remove an optional image with example and there is no image in the preview', () => {
+    cy.drupalLogin('xbUser', 'xbUser');
+    cy.loadURLandWaitForXBLoaded({ url: 'xb/node/2' });
+    cy.openLibraryPanel();
+    cy.get(
+      '[data-xb-component-id="sdc.xb_test_sdc.image-optional-with-example"]',
+    ).realClick();
+    cy.waitForElementInIframe('.layout-content img[alt="Boring placeholder"]');
+    cy.get(
+      '[class*="contextualPanel"] .js-media-library-open-button[data-once="drupal-ajax"]',
+    )
+      .first()
+      .click();
+    cy.get('div[role="dialog"]').should('exist');
+    cy.findByLabelText('Select The bones are their money').check();
+    cy.get('button:contains("Insert selected")').click();
+    cy.get('div[role="dialog"]').should('not.exist');
+    cy.waitForElementInIframe('img[alt="The bones equal dollars"]');
+    cy.get('[class*="contextualPanel"]')
+      .findByLabelText('Remove The bones are their money')
+      .click();
+
+    // Confirms the removed optional image prop is not rendered at all, vs the
+    // example/default value reappearing.
+    cy.waitForElementNotInIframe('.layout-content img');
+  });
+
+  it('Can remove an optional code component image with example and there is no image in the preview', () => {
+    cy.drupalLogin('xbUser', 'xbUser');
+    cy.loadURLandWaitForXBLoaded({ url: 'xb/node/2' });
+    cy.openLibraryPanel();
+    cy.get(
+      '[data-xb-component-id="js.xb_test_e2e_code_components_optional_image"]',
+    ).realClick();
+    cy.waitForElementInIframe(
+      '.layout-content img[alt="Example image placeholder"]',
+    );
+    cy.get(
+      '[class*="contextualPanel"] .js-media-library-open-button[data-once="drupal-ajax"]',
+    )
+      .first()
+      .click();
+    cy.get('div[role="dialog"]').should('exist');
+    cy.findByLabelText('Select The bones are their money').check();
+    cy.get('button:contains("Insert selected")').click();
+    cy.get('div[role="dialog"]').should('not.exist');
+    cy.waitForElementInIframe('img[alt="The bones equal dollars"]');
+    cy.waitForElementNotInIframe(
+      '.layout-content img[alt="Example image placeholder"]',
+    );
+    cy.findByLabelText('text').type('{selectall}{del}A new value');
+    cy.findByLabelText('text').should('have.value', 'A new value');
+    cy.waitForElementContentInIframe('p', 'A new value');
+    cy.get('[class*="contextualPanel"]')
+      .findByLabelText('Remove The bones are their money')
+      .click();
+
+    // Confirms the removed optional image prop is not rendered at all, vs the
+    // example/default value reappearing.
+    cy.waitForElementNotInIframe('.layout-content img');
+
+    // Text prop is still intact after image removal.
+    cy.waitForElementContentInIframe('p', 'A new value');
+    // Confirm other props still work.
+    cy.findByLabelText('text').type(
+      '{selectall}{del}Further changes to the value',
+    );
+    cy.findByLabelText('text').should(
+      'have.value',
+      'Further changes to the value',
+    );
+    cy.waitForElementContentInIframe('p', 'Further changes to the value');
+  });
+
+  it.only('Can remove a required code component image with example and there is no image in the preview', () => {
+    cy.drupalLogin('xbUser', 'xbUser');
+    cy.loadURLandWaitForXBLoaded({ url: 'xb/node/2' });
+    cy.openLibraryPanel();
+    cy.get(
+      '[data-xb-component-id="js.xb_test_e2e_code_components_req_image"]',
+    ).realClick();
+    cy.waitForElementInIframe(
+      '.layout-content img[alt="Example image placeholder"]',
+    );
+    cy.get(
+      '[class*="contextualPanel"] .js-media-library-open-button[data-once="drupal-ajax"]',
+    )
+      .first()
+      .click();
+    cy.get('div[role="dialog"]').should('exist');
+    cy.findByLabelText('Select The bones are their money').check();
+    cy.get('button:contains("Insert selected")').click();
+    cy.get('div[role="dialog"]').should('not.exist');
+    cy.waitForElementInIframe('img[alt="The bones equal dollars"]');
+    cy.waitForElementNotInIframe(
+      '.layout-content img[alt="Example image placeholder"]',
+    );
+    cy.findByLabelText('text').type('{selectall}{del}A new value');
+    cy.findByLabelText('text').should('have.value', 'A new value');
+    cy.waitForElementContentInIframe('p', 'A new value');
+    cy.get('[class*="contextualPanel"]')
+      .findByLabelText('Remove The bones are their money')
+      .click();
+
+    // Confirm the widget is now empty.
+    cy.get('.js-media-library-widget .field-prefix')
+      .contains('No media items are selected.')
+      .should('exist');
+    cy.get('.js-media-library-widget .description')
+      .contains('One media item remaining.')
+      .should('exist');
+
+    // The previously added image is still in the preview due to it being a
+    // required prop.
+    cy.waitForElementInIframe('img[alt="The bones equal dollars"]');
+
+    // Confirms the example does not return.
+    cy.waitForElementNotInIframe(
+      '.layout-content img[alt="Example image placeholder"]',
+    );
+
+    // Text prop is still intact after image removal.
+    cy.waitForElementContentInIframe('p', 'A new value');
+
+    cy.findByLabelText('text').type(
+      '{selectall}{del}Further changes to the value',
+    );
+    cy.findByLabelText('text').should(
+      'have.value',
+      'Further changes to the value',
+    );
+    cy.waitForElementContentInIframe('p', 'Further changes to the value');
+  });
 });
diff --git a/ui/tests/support/commands.js b/ui/tests/support/commands.js
index 1f5e133be41e49348849adc69c46f510dc66b58c..7caa25a8251be3dfb402e6e79fe45580c2d87669 100644
--- a/ui/tests/support/commands.js
+++ b/ui/tests/support/commands.js
@@ -467,6 +467,40 @@ Cypress.Commands.add(
   },
 );
 
+/**
+ * Waits for element matching a selector to not be present in an iframe.
+ *
+ * @param {string} selector
+ *   The selector of what to wait on in the iframe.
+ * @param {string} iframeSelector
+ *   The selector of the iframe to check inside. Defaults to the first preview.
+ * @param {number|null} customTimeout
+ *   Optional: If the time to wait for the element should differ from the
+ *   Cypress retry default duration.
+ */
+Cypress.Commands.add(
+  'waitForElementNotInIframe',
+  (
+    selector,
+    iframeSelector = initializedReadyPreviewIframeSelector,
+    customTimeout,
+  ) => {
+    cy.document().then((doc) => {
+      cy.get(true, {
+        timeout: customTimeout || Cypress.config('defaultCommandTimeout'),
+      }).should(() => {
+        const frameContent = doc
+          .querySelector(iframeSelector)
+          ?.contentWindow?.document?.body.querySelector(selector);
+        expect(
+          !!frameContent,
+          `'${selector}' should not be in iframe '${iframeSelector}'`,
+        ).to.equal(false);
+      });
+    });
+  },
+);
+
 Cypress.Commands.add(
   'waitForElementContentInIframe',
   (