diff --git a/src/compiler/transformers/decorators-to-static/prop-decorator.ts b/src/compiler/transformers/decorators-to-static/prop-decorator.ts
index d951b49d8a6..2ab26084e2e 100644
--- a/src/compiler/transformers/decorators-to-static/prop-decorator.ts
+++ b/src/compiler/transformers/decorators-to-static/prop-decorator.ts
@@ -98,6 +98,7 @@ const parsePropDecorator = (
 
   const propMeta: d.ComponentCompilerStaticProperty = {
     type: typeStr,
+    attribute: getAttributeName(propName, propOptions),
     mutable: !!propOptions.mutable,
     complexType: getComplexType(typeChecker, prop, type, program),
     required: prop.exclamationToken !== undefined && propName !== 'mode',
@@ -106,11 +107,12 @@ const parsePropDecorator = (
     getter: ts.isGetAccessor(prop),
     setter: !!foundSetter,
   };
-  if (ogPropName && ogPropName !== propName) propMeta.ogPropName = ogPropName;
+  if (ogPropName && ogPropName !== propName) {
+    propMeta.ogPropName = ogPropName;
+  }
 
   // prop can have an attribute if type is NOT "unknown"
   if (typeStr !== 'unknown') {
-    propMeta.attribute = getAttributeName(propName, propOptions);
     propMeta.reflect = getReflect(diagnostics, propDecorator, propOptions);
   }
 
diff --git a/src/compiler/transformers/test/convert-decorators.spec.ts b/src/compiler/transformers/test/convert-decorators.spec.ts
index 426f67b6908..018a0a22e7a 100644
--- a/src/compiler/transformers/test/convert-decorators.spec.ts
+++ b/src/compiler/transformers/test/convert-decorators.spec.ts
@@ -28,6 +28,7 @@ describe('convert-decorators', () => {
           return {
             "val": {
               "type": "string",
+              attribute: 'val',
               "mutable": false,
               "complexType": { "original": "string", "resolved": "string", "references": {} },
               "required": false,
@@ -35,7 +36,6 @@ describe('convert-decorators', () => {
               "docs": { "tags": [], "text": "" },
               "getter": false, 
               "setter": false,
-              "attribute": "val",
               "reflect": false,
               "defaultValue": "\\"initial value\\""
             }
diff --git a/src/compiler/transformers/test/parse-props.spec.ts b/src/compiler/transformers/test/parse-props.spec.ts
index 9d56d78da8a..397738c0890 100644
--- a/src/compiler/transformers/test/parse-props.spec.ts
+++ b/src/compiler/transformers/test/parse-props.spec.ts
@@ -213,6 +213,7 @@ describe('parse props', () => {
     `);
     expect(getStaticGetter(t.outputText, 'properties')).toEqual({
       val: {
+        attribute: 'val',
         complexType: {
           references: {},
           resolved: '{}', // TODO, needs to be string[]
@@ -231,7 +232,7 @@ describe('parse props', () => {
       },
     });
     expect(t.property?.type).toBe('unknown');
-    expect(t.property?.attribute).toBe(undefined);
+    expect(t.property?.attribute).toBe('val');
     expect(t.property?.reflect).toBe(false);
   });
 
@@ -819,6 +820,7 @@ describe('parse props', () => {
         return {
           val: {
             type: 'string',
+            attribute: 'val',
             mutable: false,
             complexType: { original: 'string', resolved: 'string', references: {} },
             required: false,
@@ -826,12 +828,12 @@ describe('parse props', () => {
             docs: { tags: [], text: '' },
             getter: false,
             setter: false,
-            attribute: 'val',
             reflect: false,
             defaultValue: \"'good'\",
           },
           val2: {
             type: 'string',
+            attribute: 'val-2',
             mutable: false,
             complexType: { original: 'string', resolved: 'string', references: {} },
             required: false,
@@ -840,7 +842,6 @@ describe('parse props', () => {
             getter: false,
             setter: false,
             ogPropName: 'dynVal',
-            attribute: 'val-2',
             reflect: false,
             defaultValue: \"'nice'\",
           },
diff --git a/src/hydrate/platform/proxy-host-element.ts b/src/hydrate/platform/proxy-host-element.ts
index 6db2e14c4d3..19ec218b6f4 100644
--- a/src/hydrate/platform/proxy-host-element.ts
+++ b/src/hydrate/platform/proxy-host-element.ts
@@ -41,46 +41,32 @@ export function proxyHostElement(elm: d.HostElement, cstr: d.ComponentConstructo
 
     members.forEach(([memberName, [memberFlags, metaAttributeName]]) => {
       if (memberFlags & MEMBER_FLAGS.Prop) {
+        // hyphenated attribute name
         const attributeName = metaAttributeName || memberName;
-        let attrValue = elm.getAttribute(attributeName);
-
-        /**
-         * allow hydrate parameters that contain a simple object, e.g.
-         * ```ts
-         * import { renderToString } from 'component-library/hydrate';
-         * await renderToString(`<car-detail car=${JSON.stringify({ year: 1234 })}></car-detail>`);
-         * ```
-         */
-        if (
-          (attrValue?.startsWith('{') && attrValue.endsWith('}')) ||
-          (attrValue?.startsWith('[') && attrValue.endsWith(']'))
-        ) {
-          try {
-            attrValue = JSON.parse(attrValue);
-          } catch (e) {
-            /* ignore */
-          }
-        }
-
+        // attribute value
+        const attrValue = elm.getAttribute(attributeName);
+        // property value
+        const propValue = (elm as any)[memberName];
+        let attrPropVal: any;
+        // any existing getter/setter applied to class property
         const { get: origGetter, set: origSetter } =
           Object.getOwnPropertyDescriptor((cstr as any).prototype, memberName) || {};
 
-        let attrPropVal: any;
-
         if (attrValue != null) {
+          // incoming value from `an-attribute=....`. Convert from string to correct type
           attrPropVal = parsePropertyValue(attrValue, memberFlags);
         }
 
-        const ownValue = (elm as any)[memberName];
-        if (ownValue !== undefined) {
-          attrPropVal = ownValue;
-          // we've got an actual value already set on the host element
-          // let's add that to our instance values and pull it off the element
-          // so the getter/setter kicks in instead, but still getting this value
+        if (propValue !== undefined) {
+          // incoming value set on the host element (e.g `element.aProp = ...`)
+          // let's add that to our instance values and pull it off the element.
+          // This allows any applied getter/setter to kick in instead whilst still getting this value
+          attrPropVal = propValue;
           delete (elm as any)[memberName];
         }
 
         if (attrPropVal !== undefined) {
+          // value set via attribute/prop on the host element
           if (origSetter) {
             // we have an original setter, so let's set the value via that.
             origSetter.apply(elm, [attrPropVal]);
diff --git a/src/hydrate/runner/index.ts b/src/hydrate/runner/index.ts
index ca11c860a5d..6d8016ffbca 100644
--- a/src/hydrate/runner/index.ts
+++ b/src/hydrate/runner/index.ts
@@ -1,2 +1,3 @@
 export { createWindowFromHtml } from './create-window';
 export { hydrateDocument, renderToString, serializeDocumentToString, streamToString } from './render';
+export { deserializeProperty, serializeProperty } from '@utils';
diff --git a/src/runtime/client-hydrate.ts b/src/runtime/client-hydrate.ts
index d3bcc2d954f..97bbf85a5bf 100644
--- a/src/runtime/client-hydrate.ts
+++ b/src/runtime/client-hydrate.ts
@@ -1,6 +1,7 @@
 import { BUILD } from '@app-data';
 import { plt, win } from '@platform';
-import { CMP_FLAGS } from '@utils';
+import { parsePropertyValue } from '@runtime';
+import { CMP_FLAGS, MEMBER_FLAGS } from '@utils';
 
 import type * as d from '../declarations';
 import { patchSlottedNode } from './dom-extras';
@@ -53,6 +54,24 @@ export const initializeClientHydrate = (
   const vnode: d.VNode = newVNode(tagName, null);
   vnode.$elm$ = hostElm;
 
+  /**
+   * The following forEach loop attaches properties from the element's attributes to the VNode.
+   * This is used to hydrate the VNode with the initial values of the element's attributes.
+   */
+  const members = Object.entries(hostRef.$cmpMeta$?.$members$ || {});
+  members.forEach(([memberName, [memberFlags, metaAttributeName]]) => {
+    if (!(memberFlags & MEMBER_FLAGS.Prop)) {
+      return;
+    }
+    const attributeName = metaAttributeName || memberName;
+    const attrVal = hostElm.getAttribute(attributeName);
+
+    if (attrVal !== null) {
+      const attrPropVal = parsePropertyValue(attrVal, memberFlags);
+      hostRef?.$instanceValues$?.set(memberName, attrPropVal);
+    }
+  });
+
   let scopeId: string;
   if (BUILD.scoped) {
     const cmpMeta = hostRef.$cmpMeta$;
diff --git a/src/runtime/parse-property-value.ts b/src/runtime/parse-property-value.ts
index 39d402399c8..53d53dc8941 100644
--- a/src/runtime/parse-property-value.ts
+++ b/src/runtime/parse-property-value.ts
@@ -1,5 +1,5 @@
 import { BUILD } from '@app-data';
-import { isComplexType, MEMBER_FLAGS } from '@utils';
+import { deserializeProperty, isComplexType, MEMBER_FLAGS, SERIALIZED_PREFIX } from '@utils';
 
 /**
  * Parse a new property value for a given property type.
@@ -24,32 +24,71 @@ import { isComplexType, MEMBER_FLAGS } from '@utils';
  * @param propType the type of the prop, expressed as a binary number
  * @returns the parsed/coerced value
  */
-export const parsePropertyValue = (propValue: any, propType: number): any => {
-  // ensure this value is of the correct prop type
+export const parsePropertyValue = (propValue: unknown, propType: number): any => {
+  /**
+   * Allow hydrate parameters that contain a simple object, e.g.
+   * ```ts
+   * import { renderToString } from 'component-library/hydrate';
+   * await renderToString(`<car-detail car=${JSON.stringify({ year: 1234 })}></car-detail>`);
+   * ```
+   * @deprecated
+   */
+  if (
+    (BUILD.hydrateClientSide || BUILD.hydrateServerSide) &&
+    typeof propValue === 'string' &&
+    ((propValue.startsWith('{') && propValue.endsWith('}')) || (propValue.startsWith('[') && propValue.endsWith(']')))
+  ) {
+    try {
+      propValue = JSON.parse(propValue);
+      return propValue;
+    } catch (e) {
+      /* ignore */
+    }
+  }
+
+  /**
+   * Allow hydrate parameters that contain a complex non-serialized values.
+   */
+  if (
+    (BUILD.hydrateClientSide || BUILD.hydrateServerSide) &&
+    typeof propValue === 'string' &&
+    propValue.startsWith(SERIALIZED_PREFIX)
+  ) {
+    propValue = deserializeProperty(propValue);
+    return propValue;
+  }
 
   if (propValue != null && !isComplexType(propValue)) {
+    /**
+     * ensure this value is of the correct prop type
+     */
     if (BUILD.propBoolean && propType & MEMBER_FLAGS.Boolean) {
-      // per the HTML spec, any string value means it is a boolean true value
-      // but we'll cheat here and say that the string "false" is the boolean false
+      /**
+       * per the HTML spec, any string value means it is a boolean true value
+       * but we'll cheat here and say that the string "false" is the boolean false
+       */
       return propValue === 'false' ? false : propValue === '' || !!propValue;
     }
 
+    /**
+     * force it to be a number
+     */
     if (BUILD.propNumber && propType & MEMBER_FLAGS.Number) {
-      // force it to be a number
-      return parseFloat(propValue);
+      return typeof propValue === 'string' ? parseFloat(propValue) : typeof propValue === 'number' ? propValue : NaN;
     }
 
+    /**
+     * could have been passed as a number or boolean but we still want it as a string
+     */
     if (BUILD.propString && propType & MEMBER_FLAGS.String) {
-      // could have been passed as a number or boolean
-      // but we still want it as a string
       return String(propValue);
     }
 
-    // redundant return here for better minification
     return propValue;
   }
 
-  // not sure exactly what type we want
-  // so no need to change to a different type
+  /**
+   * not sure exactly what type we want so no need to change to a different type
+   */
   return propValue;
 };
diff --git a/src/runtime/test/hydrate-prop.spec.tsx b/src/runtime/test/hydrate-prop.spec.tsx
index 30f2057b916..8906fdc7c4b 100644
--- a/src/runtime/test/hydrate-prop.spec.tsx
+++ b/src/runtime/test/hydrate-prop.spec.tsx
@@ -63,7 +63,7 @@ describe('hydrate prop types', () => {
     });
 
     expect(serverHydrated.root).toEqualHtml(`
-      <cmp-a class="hydrated" boolean="false" clamped="11" class="hydrated" num="1" s-id="1" str="hello" accessor="1">
+      <cmp-a class="hydrated" boolean="false" clamped="11" num="1" s-id="1" str="hello" accessor="1">
         <!--r.1-->
         <!--t.1.0.0.0-->
         false-hello world world-201-101-10
diff --git a/src/runtime/vdom/vdom-render.ts b/src/runtime/vdom/vdom-render.ts
index e9875b1eb3e..ea51e89dd75 100644
--- a/src/runtime/vdom/vdom-render.ts
+++ b/src/runtime/vdom/vdom-render.ts
@@ -985,13 +985,14 @@ export const renderVdom = (hostRef: d.HostRef, renderFnResults: d.VNode | d.VNod
   const hostElm = hostRef.$hostElement$;
   const cmpMeta = hostRef.$cmpMeta$;
   const oldVNode: d.VNode = hostRef.$vnode$ || newVNode(null, null);
+  const isHostElement = isHost(renderFnResults);
 
   // if `renderFnResults` is a Host node then we can use it directly. If not,
   // we need to call `h` again to wrap the children of our component in a
   // 'dummy' Host node (well, an empty vnode) since `renderVdom` assumes
   // implicitly that the top-level vdom node is 1) an only child and 2)
   // contains attrs that need to be set on the host element.
-  const rootVnode = isHost(renderFnResults) ? renderFnResults : h(null, null, renderFnResults as any);
+  const rootVnode = isHostElement ? renderFnResults : h(null, null, renderFnResults as any);
 
   hostTagName = hostElm.tagName;
 
diff --git a/src/utils/constants.ts b/src/utils/constants.ts
index b24af5ce26e..d29f4436e8f 100644
--- a/src/utils/constants.ts
+++ b/src/utils/constants.ts
@@ -259,3 +259,36 @@ export const enum NODE_TYPES {
   DOCUMENT_FRAGMENT_NODE = 11,
   NOTATION_NODE = 12,
 }
+
+/**
+ * Represents a primitive type.
+ * Described in https://w3c.github.io/webdriver-bidi/#type-script-PrimitiveProtocolValue.
+ */
+export enum PrimitiveType {
+  Undefined = 'undefined',
+  Null = 'null',
+  String = 'string',
+  Number = 'number',
+  SpecialNumber = 'number',
+  Boolean = 'boolean',
+  BigInt = 'bigint',
+}
+
+/**
+ * Represents a non-primitive type.
+ * Described in https://w3c.github.io/webdriver-bidi/#type-script-RemoteValue.
+ */
+export enum NonPrimitiveType {
+  Array = 'array',
+  Date = 'date',
+  Map = 'map',
+  Object = 'object',
+  RegularExpression = 'regexp',
+  Set = 'set',
+  Channel = 'channel',
+  Symbol = 'symbol',
+}
+
+export const TYPE_CONSTANT = 'type';
+export const VALUE_CONSTANT = 'value';
+export const SERIALIZED_PREFIX = 'serialized:';
diff --git a/src/utils/format-component-runtime-meta.ts b/src/utils/format-component-runtime-meta.ts
index 7981653d20f..9f9dbb62333 100644
--- a/src/utils/format-component-runtime-meta.ts
+++ b/src/utils/format-component-runtime-meta.ts
@@ -123,8 +123,16 @@ const formatFlags = (compilerProperty: d.ComponentCompilerProperty) => {
   return type;
 };
 
+/**
+ * We mainly add the alternative kebab-case attribute name because it might
+ * be used in an HTML environment (non JSX). Since we support hydration of
+ * complex types we provide a kebab-case attribute name for properties with
+ * these types.
+ */
+const kebabCaseSupportForTypes = ['string', 'unknown'];
+
 const formatAttrName = (compilerProperty: d.ComponentCompilerProperty) => {
-  if (typeof compilerProperty.attribute === 'string') {
+  if (kebabCaseSupportForTypes.includes(typeof compilerProperty.attribute)) {
     // string attr name means we should observe this attribute
     if (compilerProperty.name === compilerProperty.attribute) {
       // property name and attribute name are the exact same
diff --git a/src/utils/index.ts b/src/utils/index.ts
index f817e38c951..53e2c2ffd02 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -4,6 +4,7 @@ export * from './format-component-runtime-meta';
 export * from './helpers';
 export * from './is-glob';
 export * from './is-root-path';
+export * from './local-value';
 export * from './logger/logger-rollup';
 export * from './logger/logger-typescript';
 export * from './logger/logger-utils';
@@ -12,8 +13,11 @@ export * from './output-target';
 export * from './path';
 export * from './query-nonce-meta-tag-content';
 export * from './regular-expression';
+export * from './remote-value';
 export * as result from './result';
+export * from './serialize';
 export * from './sourcemaps';
+export * from './types';
 export * from './url-paths';
 export * from './util';
 export * from './validation';
diff --git a/src/utils/local-value.ts b/src/utils/local-value.ts
new file mode 100644
index 00000000000..683b9feea37
--- /dev/null
+++ b/src/utils/local-value.ts
@@ -0,0 +1,258 @@
+import { NonPrimitiveType, PrimitiveType, TYPE_CONSTANT, VALUE_CONSTANT } from './constants';
+import type { LocalValueParam, ScriptLocalValue, Serializeable } from './types';
+
+/**
+ * Represents a local value with a specified type and optional value.
+ * Described in https://w3c.github.io/webdriver-bidi/#type-script-LocalValue
+ */
+export class LocalValue {
+  type: PrimitiveType | NonPrimitiveType;
+  value?: Serializeable | Serializeable[] | [Serializeable, Serializeable][];
+
+  constructor(type: PrimitiveType | NonPrimitiveType, value?: LocalValueParam) {
+    if (type === PrimitiveType.Undefined || type === PrimitiveType.Null) {
+      this.type = type;
+    } else {
+      this.type = type;
+      this.value = value;
+    }
+  }
+
+  /**
+   * Creates a new LocalValue object with a string value.
+   *
+   * @param {string} value - The string value to be stored in the LocalValue object.
+   * @returns {LocalValue} - The created LocalValue object.
+   */
+  static createStringValue(value: string) {
+    return new LocalValue(PrimitiveType.String, value);
+  }
+
+  /**
+   * Creates a new LocalValue object with a number value.
+   *
+   * @param {number} value - The number value.
+   * @returns {LocalValue} - The created LocalValue object.
+   */
+  static createNumberValue(value: number) {
+    return new LocalValue(PrimitiveType.Number, value);
+  }
+
+  /**
+   * Creates a new LocalValue object with a special number value.
+   *
+   * @param {number} value - The value of the special number.
+   * @returns {LocalValue} - The created LocalValue object.
+   */
+  static createSpecialNumberValue(value: number) {
+    if (Number.isNaN(value)) {
+      return new LocalValue(PrimitiveType.SpecialNumber, 'NaN');
+    }
+    if (Object.is(value, -0)) {
+      return new LocalValue(PrimitiveType.SpecialNumber, '-0');
+    }
+    if (value === Infinity) {
+      return new LocalValue(PrimitiveType.SpecialNumber, 'Infinity');
+    }
+    if (value === -Infinity) {
+      return new LocalValue(PrimitiveType.SpecialNumber, '-Infinity');
+    }
+    return new LocalValue(PrimitiveType.SpecialNumber, value);
+  }
+
+  /**
+   * Creates a new LocalValue object with an undefined value.
+   * @returns {LocalValue} - The created LocalValue object.
+   */
+  static createUndefinedValue() {
+    return new LocalValue(PrimitiveType.Undefined);
+  }
+
+  /**
+   * Creates a new LocalValue object with a null value.
+   * @returns {LocalValue} - The created LocalValue object.
+   */
+  static createNullValue() {
+    return new LocalValue(PrimitiveType.Null);
+  }
+
+  /**
+   * Creates a new LocalValue object with a boolean value.
+   *
+   * @param {boolean} value - The boolean value.
+   * @returns {LocalValue} - The created LocalValue object.
+   */
+  static createBooleanValue(value: boolean) {
+    return new LocalValue(PrimitiveType.Boolean, value);
+  }
+
+  /**
+   * Creates a new LocalValue object with a BigInt value.
+   *
+   * @param {BigInt} value - The BigInt value.
+   * @returns {LocalValue} - The created LocalValue object.
+   */
+  static createBigIntValue(value: bigint) {
+    return new LocalValue(PrimitiveType.BigInt, value.toString());
+  }
+
+  /**
+   * Creates a new LocalValue object with an array.
+   *
+   * @param {Array} value - The array.
+   * @returns {LocalValue} - The created LocalValue object.
+   */
+  static createArrayValue(value: Array<unknown>) {
+    return new LocalValue(NonPrimitiveType.Array, value);
+  }
+
+  /**
+   * Creates a new LocalValue object with date value.
+   *
+   * @param {string} value - The date.
+   * @returns {LocalValue} - The created LocalValue object.
+   */
+  static createDateValue(value: Date) {
+    return new LocalValue(NonPrimitiveType.Date, value);
+  }
+
+  /**
+   * Creates a new LocalValue object of map value.
+   * @param {Map} map - The map.
+   * @returns {LocalValue} - The created LocalValue object.
+   */
+  static createMapValue(map: Map<unknown, unknown>) {
+    const value: [Serializeable, Serializeable][] = [];
+    Array.from(map.entries()).forEach(([key, val]) => {
+      value.push([LocalValue.getArgument(key), LocalValue.getArgument(val)]);
+    });
+    return new LocalValue(NonPrimitiveType.Map, value);
+  }
+
+  /**
+   * Creates a new LocalValue object from the passed object.
+   *
+   * @param object the object to create a LocalValue from
+   * @returns {LocalValue} - The created LocalValue object.
+   */
+  static createObjectValue(object: Record<string | number | symbol, unknown>) {
+    const value: [Serializeable, Serializeable][] = [];
+    Object.entries(object).forEach(([key, val]) => {
+      value.push([key, LocalValue.getArgument(val)]);
+    });
+    return new LocalValue(NonPrimitiveType.Object, value);
+  }
+
+  /**
+   * Creates a new LocalValue object of regular expression value.
+   *
+   * @param {string} value - The value of the regular expression.
+   * @returns {LocalValue} - The created LocalValue object.
+   */
+  static createRegularExpressionValue(value: { pattern: string; flags: string }) {
+    return new LocalValue(NonPrimitiveType.RegularExpression, value);
+  }
+
+  /**
+   * Creates a new LocalValue object with the specified value.
+   * @param {Set} value - The value to be set.
+   * @returns {LocalValue} - The created LocalValue object.
+   */
+  static createSetValue(value: ([unknown, unknown] | LocalValue)[]) {
+    return new LocalValue(NonPrimitiveType.Set, value);
+  }
+
+  /**
+   * Creates a new LocalValue object with the given channel value
+   *
+   * @param {ChannelValue} value - The channel value.
+   * @returns {LocalValue} - The created LocalValue object.
+   */
+  static createChannelValue(value: unknown) {
+    return new LocalValue(NonPrimitiveType.Channel, value);
+  }
+
+  /**
+   * Creates a new LocalValue object with a Symbol value.
+   *
+   * @param {Symbol} symbol - The Symbol value
+   * @returns {LocalValue} - The created LocalValue object
+   */
+  static createSymbolValue(symbol: Symbol) {
+    // Store the symbol description or 'Symbol()' if undefined
+    const description = symbol.description || 'Symbol()';
+    return new LocalValue(NonPrimitiveType.Symbol, description);
+  }
+
+  static getArgument(argument: unknown) {
+    const type = typeof argument;
+    switch (type) {
+      case PrimitiveType.String:
+        return LocalValue.createStringValue(argument as string);
+      case PrimitiveType.Number:
+        if (Number.isNaN(argument) || Object.is(argument, -0) || !Number.isFinite(argument)) {
+          return LocalValue.createSpecialNumberValue(argument as number);
+        }
+
+        return LocalValue.createNumberValue(argument as number);
+      case PrimitiveType.Boolean:
+        return LocalValue.createBooleanValue(argument as boolean);
+      case PrimitiveType.BigInt:
+        return LocalValue.createBigIntValue(argument as bigint);
+      case PrimitiveType.Undefined:
+        return LocalValue.createUndefinedValue();
+      case NonPrimitiveType.Symbol:
+        return LocalValue.createSymbolValue(argument as Symbol);
+      case NonPrimitiveType.Object:
+        if (argument === null) {
+          return LocalValue.createNullValue();
+        }
+        if (argument instanceof Date) {
+          return LocalValue.createDateValue(argument);
+        }
+        if (argument instanceof Map) {
+          const map: ([unknown, unknown] | LocalValue)[] = [];
+
+          argument.forEach((value, key) => {
+            const objectKey = typeof key === 'string' ? key : LocalValue.getArgument(key);
+            const objectValue = LocalValue.getArgument(value);
+            map.push([objectKey, objectValue]);
+          });
+          return LocalValue.createMapValue(argument as Map<unknown, unknown>);
+        }
+        if (argument instanceof Set) {
+          const set: LocalValue[] = [];
+          argument.forEach((value) => {
+            set.push(LocalValue.getArgument(value));
+          });
+          return LocalValue.createSetValue(set);
+        }
+        if (argument instanceof Array) {
+          const arr: LocalValue[] = [];
+          argument.forEach((value) => {
+            arr.push(LocalValue.getArgument(value));
+          });
+          return LocalValue.createArrayValue(arr);
+        }
+        if (argument instanceof RegExp) {
+          return LocalValue.createRegularExpressionValue({
+            pattern: argument.source,
+            flags: argument.flags,
+          });
+        }
+
+        return LocalValue.createObjectValue(argument as Record<string | number | symbol, unknown>);
+    }
+
+    throw new Error(`Unsupported type: ${type}`);
+  }
+
+  asMap() {
+    return {
+      [TYPE_CONSTANT]: this.type,
+      ...(!(this.type === PrimitiveType.Null || this.type === PrimitiveType.Undefined)
+        ? { [VALUE_CONSTANT]: this.value }
+        : {}),
+    } as ScriptLocalValue;
+  }
+}
diff --git a/src/utils/remote-value.ts b/src/utils/remote-value.ts
new file mode 100644
index 00000000000..83223423d3f
--- /dev/null
+++ b/src/utils/remote-value.ts
@@ -0,0 +1,120 @@
+import { NonPrimitiveType, PrimitiveType, TYPE_CONSTANT, VALUE_CONSTANT } from './constants';
+import type { ScriptListLocalValue, ScriptLocalValue, ScriptRegExpValue } from './types';
+
+/**
+ * RemoteValue class for deserializing LocalValue serialized objects back into their original form
+ */
+export class RemoteValue {
+  /**
+   * Deserializes a LocalValue serialized object back to its original JavaScript representation
+   *
+   * @param serialized The serialized LocalValue object
+   * @returns The original JavaScript value/object
+   */
+  static fromLocalValue(serialized: ScriptLocalValue): any {
+    const type = serialized[TYPE_CONSTANT];
+    const value = VALUE_CONSTANT in serialized ? serialized[VALUE_CONSTANT] : undefined;
+
+    switch (type) {
+      case PrimitiveType.String:
+        return value;
+
+      case PrimitiveType.Boolean:
+        return value;
+
+      case PrimitiveType.BigInt:
+        return BigInt(value as string);
+
+      case PrimitiveType.Undefined:
+        return undefined;
+
+      case PrimitiveType.Null:
+        return null;
+
+      case PrimitiveType.Number:
+        if (value === 'NaN') return NaN;
+        if (value === '-0') return -0;
+        if (value === 'Infinity') return Infinity;
+        if (value === '-Infinity') return -Infinity;
+        return value;
+
+      case NonPrimitiveType.Array:
+        return (value as ScriptLocalValue[]).map((item: ScriptLocalValue) => RemoteValue.fromLocalValue(item));
+
+      case NonPrimitiveType.Date:
+        return new Date(value as string);
+
+      case NonPrimitiveType.Map:
+        const map = new Map();
+        for (const [key, val] of value as unknown as [string, ScriptLocalValue][]) {
+          const deserializedKey = typeof key === 'object' && key !== null ? RemoteValue.fromLocalValue(key) : key;
+          const deserializedValue = RemoteValue.fromLocalValue(val);
+          map.set(deserializedKey, deserializedValue);
+        }
+        return map;
+
+      case NonPrimitiveType.Object:
+        const obj: Record<string, any> = {};
+        for (const [key, val] of value as unknown as [string, ScriptLocalValue][]) {
+          obj[key] = RemoteValue.fromLocalValue(val);
+        }
+        return obj;
+
+      case NonPrimitiveType.RegularExpression:
+        const { pattern, flags } = value as ScriptRegExpValue;
+        return new RegExp(pattern, flags);
+
+      case NonPrimitiveType.Set:
+        const set = new Set();
+        for (const item of value as unknown as ScriptListLocalValue) {
+          set.add(RemoteValue.fromLocalValue(item));
+        }
+        return set;
+
+      case NonPrimitiveType.Symbol:
+        return Symbol(value as string);
+
+      default:
+        throw new Error(`Unsupported type: ${type}`);
+    }
+  }
+
+  /**
+   * Utility method to deserialize multiple LocalValues at once
+   *
+   * @param serializedValues Array of serialized LocalValue objects
+   * @returns Array of deserialized JavaScript values
+   */
+  static fromLocalValueArray(serializedValues: ScriptLocalValue[]): any[] {
+    return serializedValues.map((value) => RemoteValue.fromLocalValue(value));
+  }
+
+  /**
+   * Verifies if the given object matches the structure of a serialized LocalValue
+   *
+   * @param obj Object to verify
+   * @returns boolean indicating if the object has LocalValue structure
+   */
+  static isLocalValueObject(obj: any): boolean {
+    if (typeof obj !== 'object' || obj === null) {
+      return false;
+    }
+
+    if (!obj.hasOwnProperty(TYPE_CONSTANT)) {
+      return false;
+    }
+
+    const type = obj[TYPE_CONSTANT];
+    const hasTypeProperty = Object.values({ ...PrimitiveType, ...NonPrimitiveType }).includes(type);
+
+    if (!hasTypeProperty) {
+      return false;
+    }
+
+    if (type !== PrimitiveType.Null && type !== PrimitiveType.Undefined) {
+      return obj.hasOwnProperty(VALUE_CONSTANT);
+    }
+
+    return true;
+  }
+}
diff --git a/src/utils/serialize.ts b/src/utils/serialize.ts
new file mode 100644
index 00000000000..e646f5dac35
--- /dev/null
+++ b/src/utils/serialize.ts
@@ -0,0 +1,35 @@
+import { SERIALIZED_PREFIX } from './constants';
+import { LocalValue } from './local-value';
+import { RemoteValue } from './remote-value';
+
+/**
+ * Serialize a value to a string that can be deserialized later.
+ * @param {unknown} value - The value to serialize.
+ * @returns {string} A string that can be deserialized later.
+ */
+export function serializeProperty(value: unknown) {
+  /**
+   * If the value is a primitive type, return it as is.
+   */
+  if (
+    ['string', 'boolean', 'undefined'].includes(typeof value) ||
+    (typeof value === 'number' && value !== Infinity && value !== -Infinity && !isNaN(value))
+  ) {
+    return value as string | number | boolean;
+  }
+
+  const arg = LocalValue.getArgument(value);
+  return (SERIALIZED_PREFIX + btoa(JSON.stringify(arg))) as string;
+}
+
+/**
+ * Deserialize a value from a string that was serialized earlier.
+ * @param {string} value - The string to deserialize.
+ * @returns {unknown} The deserialized value.
+ */
+export function deserializeProperty(value: string) {
+  if (typeof value !== 'string' || !value.startsWith(SERIALIZED_PREFIX)) {
+    return value;
+  }
+  return RemoteValue.fromLocalValue(JSON.parse(atob(value.slice(SERIALIZED_PREFIX.length))));
+}
diff --git a/src/utils/test/serialize.spec.ts b/src/utils/test/serialize.spec.ts
new file mode 100644
index 00000000000..b37a2cdd035
--- /dev/null
+++ b/src/utils/test/serialize.spec.ts
@@ -0,0 +1,113 @@
+import { deserializeProperty, serializeProperty } from '../serialize';
+
+describe('serialize', () => {
+  it('should serialize and deserialize a simple object', () => {
+    const toSerialize = { foo: 'bar' };
+    expect(deserializeProperty(serializeProperty(toSerialize) as string)).toEqual(toSerialize);
+  });
+
+  it('should serialize and deserialize a simple array', () => {
+    const toSerialize = [1, 2, 3];
+    expect(deserializeProperty(serializeProperty(toSerialize) as string)).toEqual(toSerialize);
+  });
+
+  describe('should serialize and deserialize special number values', () => {
+    const specialNumbers = [NaN, Infinity, -Infinity, -0];
+    specialNumbers.forEach((num) => {
+      it(`should serialize and deserialize ${num}`, () => {
+        const serialized = serializeProperty(num) as string;
+        const deserialized = deserializeProperty(serialized);
+        if (Number.isNaN(num)) {
+          expect(Number.isNaN(deserialized)).toBe(true);
+        } else {
+          expect(Object.is(deserialized, num)).toBe(true);
+        }
+      });
+    });
+  });
+
+  it('should serialize and deserialize complex nested structures', () => {
+    const date = new Date('2024-01-01');
+    const regex = /test/gi;
+    const toSerialize = {
+      array: [1, 'two', { three: 3 }],
+      date: date,
+      regex: regex,
+      nested: {
+        map: new Map([['key', 'value']]),
+        set: new Set([1, 2, 3]),
+      },
+    };
+
+    const deserialized = deserializeProperty(serializeProperty(toSerialize) as string);
+
+    expect(deserialized.array).toEqual([1, 'two', { three: 3 }]);
+    expect(deserialized.date).toEqual(date);
+    expect(deserialized.regex.source).toBe(regex.source);
+    expect(deserialized.regex.flags).toBe(regex.flags);
+    expect(deserialized.nested.map.get('key')).toBe('value');
+    expect(Array.from(deserialized.nested.set)).toEqual([1, 2, 3]);
+  });
+
+  it('should serialize and deserialize Map with non-string keys', () => {
+    const map = new Map([
+      [1, 'number key'],
+      [{ complex: 'key' }, 'object key'],
+      ['string', 'string key'],
+    ] as any);
+
+    const deserialized = deserializeProperty(serializeProperty(map) as string);
+    expect(deserialized.get(1)).toBe('number key');
+    expect(deserialized.get('string')).toBe('string key');
+    // Note: object keys will be serialized/deserialized but won't be strictly equal
+    expect(Array.from(deserialized.entries()).length).toBe(3);
+  });
+
+  it('should serialize and deserialize Symbols', () => {
+    const symbolKey = Symbol('test');
+    const obj = {
+      [symbolKey]: 'symbol value',
+      regularKey: Symbol('another'),
+    };
+
+    const deserialized = deserializeProperty(serializeProperty(obj) as string);
+    expect(deserialized.regularKey.description).toBe('another');
+  });
+
+  it('should handle null and undefined values', () => {
+    const toSerialize: any = {
+      nullValue: null,
+      undefinedValue: undefined,
+      nested: {
+        nullValue: null,
+        undefinedValue: undefined,
+      },
+    };
+
+    const deserialized = deserializeProperty(serializeProperty(toSerialize) as string);
+    expect(deserialized.nullValue).toBeNull();
+    expect(deserialized.undefinedValue).toBeUndefined();
+    expect(deserialized.nested.nullValue).toBeNull();
+    expect(deserialized.nested.undefinedValue).toBeUndefined();
+  });
+
+  it('should serialize and deserialize BigInt values', () => {
+    const toSerialize = {
+      bigInt: BigInt('9007199254740991'),
+      nested: {
+        bigInt: BigInt('1234567890'),
+      },
+    };
+
+    const deserialized = deserializeProperty(serializeProperty(toSerialize) as string);
+    expect(deserialized.bigInt).toBe(BigInt('9007199254740991'));
+    expect(deserialized.nested.bigInt).toBe(BigInt('1234567890'));
+  });
+
+  it('should handle circular references gracefully', () => {
+    const circular: any = { foo: 'bar' };
+    circular.self = circular;
+
+    expect(() => serializeProperty(circular)).toThrow();
+  });
+});
diff --git a/src/utils/types.ts b/src/utils/types.ts
new file mode 100644
index 00000000000..910ae5af3ea
--- /dev/null
+++ b/src/utils/types.ts
@@ -0,0 +1,95 @@
+export type Serializeable = string | number | boolean | unknown;
+export type LocalValueParam = Serializeable | Serializeable[] | [Serializeable, Serializeable][];
+
+export type ScriptLocalValue =
+  | ScriptPrimitiveProtocolValue
+  | ScriptArrayLocalValue
+  | ScriptDateLocalValue
+  | ScriptSymbolValue
+  | ScriptMapLocalValue
+  | ScriptObjectLocalValue
+  | ScriptRegExpLocalValue
+  | ScriptSetLocalValue;
+export type ScriptListLocalValue = ScriptLocalValue[];
+
+export interface ScriptArrayLocalValue {
+  type: 'array';
+  value: ScriptListLocalValue;
+}
+
+export interface ScriptDateLocalValue {
+  type: 'date';
+  value: string;
+}
+
+export type ScriptMappingLocalValue = (ScriptLocalValue | ScriptLocalValue)[];
+
+export interface ScriptMapLocalValue {
+  type: 'map';
+  value: ScriptMappingLocalValue;
+}
+
+export interface ScriptObjectLocalValue {
+  type: 'object';
+  value: ScriptMappingLocalValue;
+}
+
+export interface ScriptRegExpValue {
+  pattern: string;
+  flags?: string;
+}
+
+export interface ScriptRegExpLocalValue {
+  type: 'regexp';
+  value: ScriptRegExpValue;
+}
+
+export interface ScriptSetLocalValue {
+  type: 'set';
+  value: ScriptListLocalValue;
+}
+
+export type ScriptPreloadScript = string;
+export type ScriptRealm = string;
+export type ScriptPrimitiveProtocolValue =
+  | ScriptUndefinedValue
+  | ScriptNullValue
+  | ScriptStringValue
+  | ScriptNumberValue
+  | ScriptBooleanValue
+  | ScriptBigIntValue;
+
+export interface ScriptUndefinedValue {
+  type: 'undefined';
+}
+
+export interface ScriptNullValue {
+  type: 'null';
+}
+
+export interface ScriptStringValue {
+  type: 'string';
+  value: string;
+}
+
+export interface ScriptSymbolValue {
+  type: 'symbol';
+  value: string;
+}
+
+export type ScriptSpecialNumber = 'NaN' | '-0' | 'Infinity' | '-Infinity';
+
+export interface ScriptNumberValue {
+  type: 'number';
+  value: number | ScriptSpecialNumber;
+}
+
+export interface ScriptBooleanValue {
+  type: 'boolean';
+  value: boolean;
+}
+
+export interface ScriptBigIntValue {
+  type: 'bigint';
+  value: string;
+}
diff --git a/test/end-to-end/package-lock.json b/test/end-to-end/package-lock.json
index a75adcf8f30..d77ceb794b2 100644
--- a/test/end-to-end/package-lock.json
+++ b/test/end-to-end/package-lock.json
@@ -25,7 +25,7 @@
     },
     "../..": {
       "name": "@stencil/core",
-      "version": "4.27.1",
+      "version": "4.28.2",
       "dev": true,
       "license": "MIT",
       "bin": {
@@ -33,11 +33,12 @@
       },
       "devDependencies": {
         "@ionic/prettier-config": "^4.0.0",
-        "@rollup/plugin-commonjs": "21.1.0",
+        "@jridgewell/source-map": "^0.3.6",
+        "@rollup/plugin-commonjs": "28.0.2",
         "@rollup/plugin-json": "6.1.0",
-        "@rollup/plugin-node-resolve": "9.0.0",
-        "@rollup/plugin-replace": "5.0.7",
-        "@rollup/pluginutils": "5.1.3",
+        "@rollup/plugin-node-resolve": "16.0.0",
+        "@rollup/plugin-replace": "6.0.2",
+        "@rollup/pluginutils": "5.1.4",
         "@types/eslint": "^8.4.6",
         "@types/exit": "^0.1.31",
         "@types/fs-extra": "^11.0.0",
@@ -84,16 +85,16 @@
         "node-fetch": "3.3.2",
         "open": "^9.0.0",
         "open-in-editor": "2.2.0",
-        "parse5": "7.1.2",
+        "parse5": "7.2.1",
         "pixelmatch": "5.3.0",
         "postcss": "^8.2.8",
         "prettier": "3.3.1",
         "prompts": "2.4.2",
         "puppeteer": "^24.1.0",
         "rimraf": "^6.0.1",
-        "rollup": "2.56.3",
+        "rollup": "4.34.9",
         "semver": "^7.3.7",
-        "terser": "5.31.1",
+        "terser": "5.37.0",
         "tsx": "^4.19.2",
         "typescript": "~5.5.4",
         "webpack": "^5.75.0",
@@ -102,6 +103,16 @@
       "engines": {
         "node": ">=16.0.0",
         "npm": ">=7.10.0"
+      },
+      "optionalDependencies": {
+        "@rollup/rollup-darwin-arm64": "4.34.9",
+        "@rollup/rollup-darwin-x64": "4.34.9",
+        "@rollup/rollup-linux-arm64-gnu": "4.34.9",
+        "@rollup/rollup-linux-arm64-musl": "4.34.9",
+        "@rollup/rollup-linux-x64-gnu": "4.34.9",
+        "@rollup/rollup-linux-x64-musl": "4.34.9",
+        "@rollup/rollup-win32-arm64-msvc": "4.34.9",
+        "@rollup/rollup-win32-x64-msvc": "4.34.9"
       }
     },
     "node_modules/@ampproject/remapping": {
@@ -5354,11 +5365,20 @@
       "version": "file:../..",
       "requires": {
         "@ionic/prettier-config": "^4.0.0",
-        "@rollup/plugin-commonjs": "21.1.0",
+        "@jridgewell/source-map": "^0.3.6",
+        "@rollup/plugin-commonjs": "28.0.2",
         "@rollup/plugin-json": "6.1.0",
-        "@rollup/plugin-node-resolve": "9.0.0",
-        "@rollup/plugin-replace": "5.0.7",
-        "@rollup/pluginutils": "5.1.3",
+        "@rollup/plugin-node-resolve": "16.0.0",
+        "@rollup/plugin-replace": "6.0.2",
+        "@rollup/pluginutils": "5.1.4",
+        "@rollup/rollup-darwin-arm64": "4.34.9",
+        "@rollup/rollup-darwin-x64": "4.34.9",
+        "@rollup/rollup-linux-arm64-gnu": "4.34.9",
+        "@rollup/rollup-linux-arm64-musl": "4.34.9",
+        "@rollup/rollup-linux-x64-gnu": "4.34.9",
+        "@rollup/rollup-linux-x64-musl": "4.34.9",
+        "@rollup/rollup-win32-arm64-msvc": "4.34.9",
+        "@rollup/rollup-win32-x64-msvc": "4.34.9",
         "@types/eslint": "^8.4.6",
         "@types/exit": "^0.1.31",
         "@types/fs-extra": "^11.0.0",
@@ -5405,16 +5425,16 @@
         "node-fetch": "3.3.2",
         "open": "^9.0.0",
         "open-in-editor": "2.2.0",
-        "parse5": "7.1.2",
+        "parse5": "7.2.1",
         "pixelmatch": "5.3.0",
         "postcss": "^8.2.8",
         "prettier": "3.3.1",
         "prompts": "2.4.2",
         "puppeteer": "^24.1.0",
         "rimraf": "^6.0.1",
-        "rollup": "2.56.3",
+        "rollup": "4.34.9",
         "semver": "^7.3.7",
-        "terser": "5.31.1",
+        "terser": "5.37.0",
         "tsx": "^4.19.2",
         "typescript": "~5.5.4",
         "webpack": "^5.75.0",
diff --git a/test/end-to-end/src/car-detail/readme.md b/test/end-to-end/src/car-detail/readme.md
index 9e96ff7d5a9..323bf9db021 100644
--- a/test/end-to-end/src/car-detail/readme.md
+++ b/test/end-to-end/src/car-detail/readme.md
@@ -9,7 +9,7 @@
 
 | Property | Attribute | Description | Type      | Default     |
 | -------- | --------- | ----------- | --------- | ----------- |
-| `car`    | --        |             | `CarData` | `undefined` |
+| `car`    | `car`     |             | `CarData` | `undefined` |
 
 
 ## Dependencies
diff --git a/test/end-to-end/src/car-list/readme.md b/test/end-to-end/src/car-list/readme.md
index 5c7b1d348cb..d79a51cc217 100644
--- a/test/end-to-end/src/car-list/readme.md
+++ b/test/end-to-end/src/car-list/readme.md
@@ -11,10 +11,10 @@ Component that helps display a list of cars
 
 ## Properties
 
-| Property   | Attribute | Description | Type        | Default     |
-| ---------- | --------- | ----------- | ----------- | ----------- |
-| `cars`     | --        |             | `CarData[]` | `undefined` |
-| `selected` | --        |             | `CarData`   | `undefined` |
+| Property   | Attribute  | Description | Type        | Default     |
+| ---------- | ---------- | ----------- | ----------- | ----------- |
+| `cars`     | `cars`     |             | `CarData[]` | `undefined` |
+| `selected` | `selected` |             | `CarData`   | `undefined` |
 
 
 ## Events
diff --git a/test/end-to-end/src/declarative-shadow-dom/readme.md b/test/end-to-end/src/declarative-shadow-dom/readme.md
index c9cd4edb487..318e4a524f5 100644
--- a/test/end-to-end/src/declarative-shadow-dom/readme.md
+++ b/test/end-to-end/src/declarative-shadow-dom/readme.md
@@ -11,10 +11,10 @@ Component that helps display a list of cars
 
 ## Properties
 
-| Property   | Attribute | Description | Type        | Default     |
-| ---------- | --------- | ----------- | ----------- | ----------- |
-| `cars`     | --        |             | `CarData[]` | `undefined` |
-| `selected` | --        |             | `CarData`   | `undefined` |
+| Property   | Attribute  | Description | Type        | Default     |
+| ---------- | ---------- | ----------- | ----------- | ----------- |
+| `cars`     | `cars`     |             | `CarData[]` | `undefined` |
+| `selected` | `selected` |             | `CarData`   | `undefined` |
 
 
 ## Events
diff --git a/test/end-to-end/src/ssr-runtime-decorators/ssr-runtime-decorators.e2e.ts b/test/end-to-end/src/ssr-runtime-decorators/ssr-runtime-decorators.e2e.ts
index 0b07cc01676..e9953983867 100644
--- a/test/end-to-end/src/ssr-runtime-decorators/ssr-runtime-decorators.e2e.ts
+++ b/test/end-to-end/src/ssr-runtime-decorators/ssr-runtime-decorators.e2e.ts
@@ -108,7 +108,7 @@ describe('different types of decorated properties and states render on both serv
     expect(await txt('decoratedState')).toBe('10');
   });
 
-  it('renders different values on different component instances ', async () => {
+  it('renders different values on different component instances', async () => {
     const doc = await renderToString(`
       <runtime-decorators></runtime-decorators>
       <runtime-decorators 
diff --git a/test/wdio/complex-properties/__snapshots__/cmp.test.tsx.snap b/test/wdio/complex-properties/__snapshots__/cmp.test.tsx.snap
new file mode 100644
index 00000000000..6165cfe0c3c
--- /dev/null
+++ b/test/wdio/complex-properties/__snapshots__/cmp.test.tsx.snap
@@ -0,0 +1,43 @@
+// Snapshot v1
+
+exports[`complex-properties > should render complex properties 1`] = `
+"<complex-properties baz="serialized:eyJ0eXBlIjoibWFwIiwidmFsdWUiOltbeyJ0eXBlIjoic3RyaW5nIiwidmFsdWUiOiJmb28ifSx7InR5cGUiOiJvYmplY3QiLCJ2YWx1ZSI6W1sicXV4Iix7InR5cGUiOiJzeW1ib2wiLCJ2YWx1ZSI6InF1dXgifV1dfV1dfQ==" class="hydrated" corge="serialized:eyJ0eXBlIjoic2V0IiwidmFsdWUiOlt7InR5cGUiOiJvYmplY3QiLCJ2YWx1ZSI6W1siZm9vIix7InR5cGUiOiJvYmplY3QiLCJ2YWx1ZSI6W1siYmFyIix7InR5cGUiOiJzdHJpbmciLCJ2YWx1ZSI6ImZvbyJ9XV19XV19XX0=" foo="serialized:eyJ0eXBlIjoib2JqZWN0IiwidmFsdWUiOltbImJhciIseyJ0eXBlIjoibnVtYmVyIiwidmFsdWUiOjEyM31dLFsibG9vIix7InR5cGUiOiJhcnJheSIsInZhbHVlIjpbeyJ0eXBlIjoibnVtYmVyIiwidmFsdWUiOjF9LHsidHlwZSI6Im51bWJlciIsInZhbHVlIjoyfSx7InR5cGUiOiJudW1iZXIiLCJ2YWx1ZSI6M31dfV0sWyJxdXgiLHsidHlwZSI6Im9iamVjdCIsInZhbHVlIjpbWyJxdXV4Iix7InR5cGUiOiJzeW1ib2wiLCJ2YWx1ZSI6InF1dXgifV1dfV1dfQ==" grault="serialized:eyJ0eXBlIjoibnVtYmVyIiwidmFsdWUiOiJJbmZpbml0eSJ9" kids-names="serialized:eyJ0eXBlIjoiYXJyYXkiLCJ2YWx1ZSI6W3sidHlwZSI6InN0cmluZyIsInZhbHVlIjoiSm9obiJ9LHsidHlwZSI6InN0cmluZyIsInZhbHVlIjoiSmFuZSJ9LHsidHlwZSI6InN0cmluZyIsInZhbHVlIjoiSmltIn1dfQ==" quux="serialized:eyJ0eXBlIjoic2V0IiwidmFsdWUiOlt7InR5cGUiOiJzdHJpbmciLCJ2YWx1ZSI6ImZvbyJ9XX0=" s-id="1" waldo="serialized:eyJ0eXBlIjoibnVsbCJ9">
+  <template shadowrootmode="open">
+    <ul c-id="1.0.0.0">
+      <li c-id="1.1.1.0">
+        <!--t.1.2.2.0-->
+        this.foo.bar: 123
+      </li>
+      <li c-id="1.3.1.1">
+        <!--t.1.4.2.0-->
+        this.foo.loo: 1, 2, 3
+      </li>
+      <li c-id="1.5.1.2">
+        <!--t.1.6.2.0-->
+        this.foo.qux: symbol
+      </li>
+      <li c-id="1.7.1.3">
+        <!--t.1.8.2.0-->
+        this.baz.get('foo'): symbol
+      </li>
+      <li c-id="1.9.1.4">
+        <!--t.1.10.2.0-->
+        this.quux.has('foo'): true
+      </li>
+      <li c-id="1.11.1.5">
+        <!--t.1.12.2.0-->
+        this.grault: true
+      </li>
+      <li c-id="1.13.1.6">
+        <!--t.1.14.2.0-->
+        this.waldo: true
+      </li>
+      <li c-id="1.15.1.7">
+        <!--t.1.16.2.0-->
+        this.kidsNames: John, Jane, Jim
+      </li>
+    </ul>
+  </template>
+  <!--r.1-->
+</complex-properties>"
+`;
diff --git a/test/wdio/complex-properties/cmp.test.tsx b/test/wdio/complex-properties/cmp.test.tsx
new file mode 100644
index 00000000000..25451a8a86c
--- /dev/null
+++ b/test/wdio/complex-properties/cmp.test.tsx
@@ -0,0 +1,63 @@
+/// <reference types="../dist/components.d.ts" />
+import { render } from '@wdio/browser-runner/stencil';
+import { $, expect } from '@wdio/globals';
+
+import { renderToString, serializeProperty } from '../hydrate/index.mjs';
+
+const template = `<complex-properties
+  foo=${serializeProperty({ bar: 123, loo: [1, 2, 3], qux: { quux: Symbol('quux') } })}
+  baz=${serializeProperty(new Map([['foo', { qux: Symbol('quux') }]]))}
+  quux=${serializeProperty(new Set(['foo']))}
+  corge=${serializeProperty(new Set([{ foo: { bar: 'foo' } }]))}
+  grault=${serializeProperty(Infinity)}
+  waldo=${serializeProperty(null)}
+  kids-names=${serializeProperty(['John', 'Jane', 'Jim'])}
+/>`;
+
+describe('complex-properties', () => {
+  it('should render complex properties', async () => {
+    const { html } = await renderToString(template, {
+      prettyHtml: true,
+      fullDocument: false,
+    });
+    expect(html).toMatchSnapshot();
+  });
+
+  it('can render component and update properties', async () => {
+    const { html } = await renderToString(template, {
+      fullDocument: false,
+    });
+
+    render({ html, components: [] });
+    await expect($('complex-properties')).toHaveText(
+      [
+        `this.foo.bar: 123`,
+        `this.foo.loo: 1, 2, 3`,
+        `this.foo.qux: symbol`,
+        `this.baz.get('foo'): symbol`,
+        `this.quux.has('foo'): true`,
+        `this.grault: true`,
+        `this.waldo: true`,
+        `this.kidsNames: John, Jane, Jim`,
+      ].join('\n'),
+    );
+  });
+
+  it('can change a complex property and see it updated correctly', async () => {
+    const elm = document.querySelector('complex-properties') as HTMLComplexPropertiesElement;
+    elm.foo = { bar: '456', loo: [4, 5, 6], qux: { quux: Symbol('new quux') } };
+    elm.kidsNames.push('Jill');
+    await expect(elm).toHaveText(
+      [
+        `this.foo.bar: 456`,
+        `this.foo.loo: 4, 5, 6`,
+        `this.foo.qux: symbol`,
+        `this.baz.get('foo'): symbol`,
+        `this.quux.has('foo'): true`,
+        `this.grault: true`,
+        `this.waldo: true`,
+        `this.kidsNames: John, Jane, Jim, Jill`,
+      ].join('\n'),
+    );
+  });
+});
diff --git a/test/wdio/complex-properties/cmp.tsx b/test/wdio/complex-properties/cmp.tsx
new file mode 100644
index 00000000000..158f2c0dc33
--- /dev/null
+++ b/test/wdio/complex-properties/cmp.tsx
@@ -0,0 +1,68 @@
+import { Component, h, Prop } from '@stencil/core';
+
+@Component({
+  tag: 'complex-properties',
+  shadow: true,
+})
+export class ComplexProperties {
+  /**
+   * basic object
+   */
+  @Prop() foo: { bar: string; loo: number[]; qux: { quux: symbol } };
+
+  /**
+   * map objects
+   */
+  @Prop() baz: Map<string, { qux: symbol }>;
+
+  /**
+   * set objects
+   */
+  @Prop() quux: Set<string>;
+
+  /**
+   * infinity
+   */
+  @Prop() grault: typeof Infinity;
+
+  /**
+   * null
+   */
+  @Prop() waldo: null;
+
+  /**
+   * basic array
+   */
+  @Prop() kidsNames: string[];
+
+  render() {
+    return (
+      <ul>
+        <li>
+          {`this.foo.bar`}: {this.foo.bar}
+        </li>
+        <li>
+          {`this.foo.loo`}: {this.foo.loo.join(', ')}
+        </li>
+        <li>
+          {`this.foo.qux`}: {typeof this.foo.qux.quux}
+        </li>
+        <li>
+          {`this.baz.get('foo')`}: {typeof this.baz.get('foo')?.qux}
+        </li>
+        <li>
+          {`this.quux.has('foo')`}: {this.quux.has('foo') ? 'true' : 'false'}
+        </li>
+        <li>
+          {`this.grault`}: {this.grault === Infinity ? 'true' : 'false'}
+        </li>
+        <li>
+          {`this.waldo`}: {this.waldo === null ? 'true' : 'false'}
+        </li>
+        <li>
+          {`this.kidsNames`}: {this.kidsNames?.join(', ')}
+        </li>
+      </ul>
+    );
+  }
+}
diff --git a/test/wdio/declarative-shadow-dom/cmp.test.tsx b/test/wdio/declarative-shadow-dom/cmp.test.tsx
index 0d04224debf..9ceb8a46bee 100644
--- a/test/wdio/declarative-shadow-dom/cmp.test.tsx
+++ b/test/wdio/declarative-shadow-dom/cmp.test.tsx
@@ -1,4 +1,5 @@
 import { render } from '@wdio/browser-runner/stencil';
+import { $, expect } from '@wdio/globals';
 
 import { renderToString } from '../hydrate/index.mjs';
 
@@ -11,7 +12,7 @@ describe('dsd-cmp', () => {
     });
 
     expect(html).toContain('I am rendered on the Server!');
-    render({ html });
+    render({ html, components: [] });
     await expect($('dsd-cmp')).toHaveText('I am rendered on the Client!');
   });
 });
diff --git a/test/wdio/package-lock.json b/test/wdio/package-lock.json
index 90e4e5e4271..c7040981f1d 100644
--- a/test/wdio/package-lock.json
+++ b/test/wdio/package-lock.json
@@ -26,7 +26,7 @@
     },
     "../..": {
       "name": "@stencil/core",
-      "version": "4.27.1",
+      "version": "4.28.2",
       "dev": true,
       "license": "MIT",
       "bin": {
@@ -34,11 +34,12 @@
       },
       "devDependencies": {
         "@ionic/prettier-config": "^4.0.0",
-        "@rollup/plugin-commonjs": "21.1.0",
+        "@jridgewell/source-map": "^0.3.6",
+        "@rollup/plugin-commonjs": "28.0.2",
         "@rollup/plugin-json": "6.1.0",
-        "@rollup/plugin-node-resolve": "9.0.0",
-        "@rollup/plugin-replace": "5.0.7",
-        "@rollup/pluginutils": "5.1.3",
+        "@rollup/plugin-node-resolve": "16.0.0",
+        "@rollup/plugin-replace": "6.0.2",
+        "@rollup/pluginutils": "5.1.4",
         "@types/eslint": "^8.4.6",
         "@types/exit": "^0.1.31",
         "@types/fs-extra": "^11.0.0",
@@ -85,16 +86,16 @@
         "node-fetch": "3.3.2",
         "open": "^9.0.0",
         "open-in-editor": "2.2.0",
-        "parse5": "7.1.2",
+        "parse5": "7.2.1",
         "pixelmatch": "5.3.0",
         "postcss": "^8.2.8",
         "prettier": "3.3.1",
         "prompts": "2.4.2",
         "puppeteer": "^24.1.0",
         "rimraf": "^6.0.1",
-        "rollup": "2.56.3",
+        "rollup": "4.34.9",
         "semver": "^7.3.7",
-        "terser": "5.31.1",
+        "terser": "5.37.0",
         "tsx": "^4.19.2",
         "typescript": "~5.5.4",
         "webpack": "^5.75.0",
@@ -103,6 +104,16 @@
       "engines": {
         "node": ">=16.0.0",
         "npm": ">=7.10.0"
+      },
+      "optionalDependencies": {
+        "@rollup/rollup-darwin-arm64": "4.34.9",
+        "@rollup/rollup-darwin-x64": "4.34.9",
+        "@rollup/rollup-linux-arm64-gnu": "4.34.9",
+        "@rollup/rollup-linux-arm64-musl": "4.34.9",
+        "@rollup/rollup-linux-x64-gnu": "4.34.9",
+        "@rollup/rollup-linux-x64-musl": "4.34.9",
+        "@rollup/rollup-win32-arm64-msvc": "4.34.9",
+        "@rollup/rollup-win32-x64-msvc": "4.34.9"
       }
     },
     "node_modules/@ampproject/remapping": {
diff --git a/test/wdio/package.json b/test/wdio/package.json
index 4f55ff8a066..7825b9d01d9 100644
--- a/test/wdio/package.json
+++ b/test/wdio/package.json
@@ -4,7 +4,7 @@
   "version": "0.0.0",
   "scripts": {
     "build": "run-s build.no-external-runtime build.test-sibling build.main build.global-script build.prerender build.invisible-prehydration build.es2022",
-    "build.main": "node ../../bin/stencil build --debug --es5",
+    "build.main": "node ../../bin/stencil build --debug --es5 && cp src/components.d.ts dist/components.d.ts",
     "build.es2022": "node ../../bin/stencil build --debug --config stencil.config-es2022.ts",
     "build.global-script": "node ../../bin/stencil build --debug --es5 --config global-script.stencil.config.ts",
     "build.test-sibling": "cd test-sibling && npm run build",