Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions ad4m-hooks/react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import { toCustomElement } from "./register";
import { usePerspective } from "./usePerspective";
import { usePerspectives } from "./usePerspectives";
import { useModel } from "./useModel";
import { reactToWebComponent } from "./reactToWebComponent";

export {
reactToWebComponent,
toCustomElement,
useAgent,
useMe,
Expand Down
238 changes: 238 additions & 0 deletions ad4m-hooks/react/src/reactToWebComponent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
// Minimal runtime contract for React/ReactDOM (so this file is framework-agnostic)
type ReactLike = {
createElement: (...args: any[]) => any;
};
type ReactDOMLike = {
createRoot: (container: Element | DocumentFragment) => {
render: (el: any) => void;
unmount: () => void;
};
};

type Options = {
/** Tag name to register (you can also call `customElements.define` yourself). */
tagName?: string;
/** Use Shadow DOM? If false, render into the element itself. */
shadow?: boolean | ShadowRootInit;
/** Attribute prefix to treat as props (e.g. data-foo -> foo). Empty = all attributes. */
attrPrefix?: string; // default: '' (accept all)
/** Element property names to expose as pass-through React props (object/class friendly). */
observedProps?: string[];
/** Initial props (merged shallowly before anything else). */
initialProps?: Record<string, any>;
/** Optional styles to inject (adoptedStyleSheets or <style> text). */
styles?: CSSStyleSheet[] | string;
};

/**
* Turn a React component into a Web Component (Custom Element).
* - Arbitrary props (objects/classes/functions) supported via `el.props = {...}` or `el.setProps({...})`.
* - Optionally define specific element properties that map directly to React props via `observedProps`.
* - Attribute values are parsed into primitives/JSON when possible.
* - Light-DOM children are supported via <slot/> passed as `children`.
*/
Comment on lines +31 to +33
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Clarify docs: slot-based children work only with Shadow DOM.

In light DOM there’s no slotting; advise passing children via props or enable shadow.

- * - Light-DOM children are supported via <slot/> passed as `children`.
+ * - Children projection via <slot/> works with Shadow DOM only.
+ *   In light DOM, pass children via props (or enable `shadow`).
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
* - Attribute values are parsed into primitives/JSON when possible.
* - Light-DOM children are supported via <slot/> passed as `children`.
*/
* - Attribute values are parsed into primitives/JSON when possible.
* - Children projection via <slot/> works with Shadow DOM only.
* In light DOM, pass children via props (or enable `shadow`).
*/
🤖 Prompt for AI Agents
In ad4m-hooks/react/src/reactToWebComponent.ts around lines 31 to 33, update the
documentation to clarify that slot-based children are only supported when the
component uses Shadow DOM; in Light DOM there is no slotting so consumers should
either pass children via props or enable Shadow DOM on the web component. Adjust
the comment text to explicitly state this limitation and provide a brief example
or note recommending props for Light DOM usage or how to enable shadow when slot
behavior is required.

export function reactToWebComponent<P extends object>(
ReactComponent: (props: P) => any | any,
React: ReactLike,
ReactDOM: ReactDOMLike,
{
tagName,
shadow = { mode: "open" },
attrPrefix = "",
observedProps = [],
initialProps = {},
styles,
}: Options = {}
) {
const RESERVED_KEYS = new Set([
"_root",
"_mount",
"_props",
"_renderQueued",
"_observer",
"_cleanup",
"props",
"setProps",
"forceUpdate",
]);

// Util: convert kebab-case to camelCase
const toCamel = (s: string) =>
s.replace(/-([a-z])/g, (_m, c) => c.toUpperCase());

// Util: parse attribute text into a value (boolean/number/json)
const parseAttr = (raw: string) => {
const v = raw.trim();
if (v === "") return ""; // empty string stays empty
if (v === "true") return true;
if (v === "false") return false;
if (v === "null") return null;
if (v === "undefined") return undefined;
// number?
if (/^[+-]?\d+(\.\d+)?$/.test(v)) return Number(v);
// try JSON (objects/arrays)
if (
(v.startsWith("{") && v.endsWith("}")) ||
(v.startsWith("[") && v.endsWith("]"))
) {
try {
return JSON.parse(v);
} catch {
/* fallthrough */
}
}
return raw; // as-is string
};
Comment on lines +64 to +85
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add protection against prototype pollution in JSON parsing.

The parseAttr function parses JSON without validation, which could potentially be exploited if malicious attributes contain keys like __proto__, constructor, or prototype.

Consider validating parsed JSON objects:

      try {
-        return JSON.parse(v);
+        const parsed = JSON.parse(v);
+        // Basic prototype pollution protection
+        if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
+          const hasUnsafeKeys = Object.keys(parsed).some(key => 
+            key === '__proto__' || key === 'constructor' || key === 'prototype'
+          );
+          if (hasUnsafeKeys) {
+            console.warn(`Potentially unsafe JSON attribute ignored: ${v}`);
+            return raw;
+          }
+        }
+        return parsed;
      } catch {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const parseAttr = (raw: string) => {
const v = raw.trim();
if (v === "") return ""; // empty string stays empty
if (v === "true") return true;
if (v === "false") return false;
if (v === "null") return null;
if (v === "undefined") return undefined;
// number?
if (/^[+-]?\d+(\.\d+)?$/.test(v)) return Number(v);
// try JSON (objects/arrays)
if (
(v.startsWith("{") && v.endsWith("}")) ||
(v.startsWith("[") && v.endsWith("]"))
) {
try {
return JSON.parse(v);
} catch {
/* fallthrough */
}
}
return raw; // as-is string
};
const parseAttr = (raw: string) => {
const v = raw.trim();
if (v === "") return ""; // empty string stays empty
if (v === "true") return true;
if (v === "false") return false;
if (v === "null") return null;
if (v === "undefined") return undefined;
// number?
if (/^[+-]?\d+(\.\d+)?$/.test(v)) return Number(v);
// try JSON (objects/arrays)
if (
(v.startsWith("{") && v.endsWith("}")) ||
(v.startsWith("[") && v.endsWith("]"))
) {
try {
const parsed = JSON.parse(v);
// Basic prototype pollution protection
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const hasUnsafeKeys = Object.keys(parsed).some(key =>
key === '__proto__' || key === 'constructor' || key === 'prototype'
);
if (hasUnsafeKeys) {
console.warn(`Potentially unsafe JSON attribute ignored: ${v}`);
return raw;
}
}
return parsed;
} catch {
/* fallthrough */
}
}
return raw; // as-is string
};
🤖 Prompt for AI Agents
In ad4m-hooks/react/src/reactToWebComponent.ts around lines 64 to 85, the
parseAttr JSON branch does an unvalidated JSON.parse which can allow
prototype-pollution keys; after parsing, validate the result recursively and
reject any object that contains keys "__proto__", "constructor" or "prototype"
(including nested objects and arrays), or use JSON.parse with a reviver that
throws when those keys are encountered; if validation fails, fall back to
returning the original raw string (or null/undefined as appropriate) instead of
the parsed object, ensuring only safe plain objects/arrays are returned.


// Util: schedule a single render per microtask
function enqueueRender(el: any) {
if (el._renderQueued) return;
el._renderQueued = true;
queueMicrotask(() => {
el._renderQueued = false;
el._render();
});
}

// Define the custom element class
class ReactCustomElement extends HTMLElement {
private _root!: ReturnType<ReactDOMLike["createRoot"]>;
private _mount!: HTMLElement;
private _props: Record<string, any> = {};
private _observer?: MutationObserver;
private _renderQueued = false;

constructor() {
super();

// Where React renders
let host: ShadowRoot | HTMLElement;
if (shadow) {
const init: ShadowRootInit =
typeof shadow === "object" ? shadow : { mode: "open" };
host = this.attachShadow(init);
// Inject styles
if (styles) {
if (
Array.isArray(styles) &&
(host as any).adoptedStyleSheets !== undefined
) {
(host as any).adoptedStyleSheets = [
...(host as any).adoptedStyleSheets,
...styles,
];
} else {
const styleEl = document.createElement("style");
styleEl.textContent = Array.isArray(styles) ? "" : String(styles);
host.appendChild(styleEl);
}
}
} else {
host = this;
}

Comment on lines 110 to 135
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Potential memory leak with styles in non-Shadow DOM mode.

When shadow is false and styles are provided, the styles are not applied but the configuration suggests they should be. This could confuse users who expect styles to work in light DOM mode.

Either apply styles in light DOM mode or document that styles only work with Shadow DOM:

      } else {
        host = this;
+        // Apply styles to light DOM if provided
+        if (styles && !Array.isArray(styles)) {
+          const styleEl = document.createElement("style");
+          styleEl.textContent = String(styles);
+          this.appendChild(styleEl);
+        }
      }

Alternatively, consider throwing an error or warning when styles are provided without Shadow DOM enabled.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Where React renders
let host: ShadowRoot | HTMLElement;
if (shadow) {
const init: ShadowRootInit =
typeof shadow === "object" ? shadow : { mode: "open" };
host = this.attachShadow(init);
// Inject styles
if (styles) {
if (
Array.isArray(styles) &&
(host as any).adoptedStyleSheets !== undefined
) {
(host as any).adoptedStyleSheets = [
...(host as any).adoptedStyleSheets,
...styles,
];
} else {
const styleEl = document.createElement("style");
styleEl.textContent = Array.isArray(styles) ? "" : String(styles);
host.appendChild(styleEl);
}
}
} else {
host = this;
}
// Where React renders
let host: ShadowRoot | HTMLElement;
if (shadow) {
const init: ShadowRootInit =
typeof shadow === "object" ? shadow : { mode: "open" };
host = this.attachShadow(init);
// Inject styles
if (styles) {
if (
Array.isArray(styles) &&
(host as any).adoptedStyleSheets !== undefined
) {
(host as any).adoptedStyleSheets = [
...(host as any).adoptedStyleSheets,
...styles,
];
} else {
const styleEl = document.createElement("style");
styleEl.textContent = Array.isArray(styles) ? "" : String(styles);
host.appendChild(styleEl);
}
}
} else {
host = this;
// Apply styles to light DOM if provided
if (styles && !Array.isArray(styles)) {
const styleEl = document.createElement("style");
styleEl.textContent = String(styles);
this.appendChild(styleEl);
}
}
🤖 Prompt for AI Agents
In ad4m-hooks/react/src/reactToWebComponent.ts around lines 108 to 133, styles
are only injected when a ShadowRoot is created, so when shadow is false and
styles are provided they are ignored; update the branch that sets host = this to
handle styles as well: if styles is provided and shadow is false, either create
and append a <style> element to the host (converting array/other types to string
like the shadow branch does) or adopt the provided styleSheets into
document.styleSheets (or document.head) as appropriate, and add a console.warn
fallback if you prefer to notify consumers that styles are being applied
globally rather than in a shadow root; ensure you mirror the same array/string
handling logic used for the Shadow DOM case and avoid duplicating style elements
on repeated renders.

this._mount = document.createElement("div");
host.appendChild(this._mount);
this._root = ReactDOM.createRoot(this._mount);

// Initialize props
this._props = { ...initialProps };

// Define a unified props setter/getter for arbitrary values
Object.defineProperty(this, "props", {
get: () => ({ ...this._props }),
set: (next: Record<string, any>) => {
if (next && typeof next === "object") {
Object.assign(this._props, next);
enqueueRender(this);
}
},
});

// A convenience method for partial updates
(this as any).setProps = (patch: Record<string, any>) => {
if (patch && typeof patch === "object") {
Object.assign(this._props, patch);
enqueueRender(this);
}
};

// Expose a forceUpdate if someone needs it
(this as any).forceUpdate = () => enqueueRender(this);

// Define pass-through element properties (object/class safe)
for (const key of observedProps) {
if (RESERVED_KEYS.has(key)) continue;
if (Object.prototype.hasOwnProperty.call(this, key)) continue; // don't clobber instance fields
Object.defineProperty(this, key, {
get: () => this._props[key],
set: (val: any) => {
this._props[key] = val;
enqueueRender(this);
},
configurable: true,
enumerable: true,
});
}

// Attribute observer (prefix-aware; empty prefix = all)
this._observer = new MutationObserver((records) => {
for (const r of records) {
if (r.type !== "attributes" || !r.attributeName) continue;
const attr = r.attributeName;
if (attrPrefix && !attr.startsWith(attrPrefix)) continue;

const logical = toCamel(
attrPrefix ? attr.slice(attrPrefix.length) : attr
);
const raw = this.getAttribute(attr);
const val = raw === null ? undefined : parseAttr(raw);
// undefined deletes the prop (useful when attribute removed)
if (val === undefined) delete this._props[logical];
else this._props[logical] = val;
}
enqueueRender(this);
});
this._observer.observe(this, { attributes: true });

// Bootstrap from existing attributes once
for (const attr of Array.from(this.attributes)) {
const name = attr.name;
if (attrPrefix && !name.startsWith(attrPrefix)) continue;
const logical = toCamel(
attrPrefix ? name.slice(attrPrefix.length) : name
);
this._props[logical] = parseAttr(attr.value);
}
}

connectedCallback() {
// Kick initial render
enqueueRender(this);
}

disconnectedCallback() {
this._observer?.disconnect();
this._root.unmount();
}
Comment on lines 226 to 229
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Missing cleanup for styles and mount element.

The disconnectedCallback doesn't clean up the mount element or injected styles, which could cause memory leaks if elements are frequently attached/detached.

Add proper cleanup:

     disconnectedCallback() {
       this._observer?.disconnect();
       this._root.unmount();
+      // Clean up mount element
+      this._mount?.remove();
+      // Clean up styles if in light DOM
+      if (!shadow && styles) {
+        const styleEl = this.querySelector('style');
+        styleEl?.remove();
+      }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
disconnectedCallback() {
this._observer?.disconnect();
this._root.unmount();
}
disconnectedCallback() {
this._observer?.disconnect();
this._root.unmount();
// Clean up mount element
this._mount?.remove();
// Clean up styles if in light DOM
if (!shadow && styles) {
const styleEl = this.querySelector('style');
styleEl?.remove();
}
}


// Render React with <slot/> as children (works in shadow and light DOM)
private _render() {
const element = React.createElement(ReactComponent as any, {
...this._props,
host: this,
children: React.createElement("slot"),
});
this._root.render(element);
}
}

// Optionally register
if (tagName) {
if (!customElements.get(tagName)) {
customElements.define(tagName, ReactCustomElement);
}
}

return ReactCustomElement;
}