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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
251 changes: 251 additions & 0 deletions ad4m-hooks/react/src/reactToWebComponent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
// 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 _host!: ShadowRoot | HTMLElement;
private _props: Record<string, any> = {};
private _observer?: MutationObserver;
private _renderQueued = false;
private _isMounted = false;

constructor() {
super();

// Where React renders
if (shadow) {
const init: ShadowRootInit =
typeof shadow === "object" ? shadow : { mode: "open" };
this._host = this.attachShadow(init);
// Inject styles
if (styles) {
if (
Array.isArray(styles) &&
(this._host as any).adoptedStyleSheets !== undefined
) {
(this._host as any).adoptedStyleSheets = [
...(this._host as any).adoptedStyleSheets,
...styles,
];
} else {
const styleEl = document.createElement("style");
styleEl.textContent = Array.isArray(styles) ? "" : String(styles);
this._host.appendChild(styleEl);
}
}
Comment on lines +116 to +130
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

Avoid silently dropping styles when CSSStyleSheet[] is provided but adoptedStyleSheets is unsupported.

Current fallback injects an empty <style>, losing all CSS. Warn instead, and only inject text when a string is supplied.

Apply this diff:

-        if (styles) {
-          if (
-            Array.isArray(styles) &&
-            (this._host as any).adoptedStyleSheets !== undefined
-          ) {
-            (this._host as any).adoptedStyleSheets = [
-              ...(this._host as any).adoptedStyleSheets,
-              ...styles,
-            ];
-          } else {
-            const styleEl = document.createElement("style");
-            styleEl.textContent = Array.isArray(styles) ? "" : String(styles);
-            this._host.appendChild(styleEl);
-          }
-        }
+        if (styles) {
+          const supportsAdoption =
+            (this._host as any).adoptedStyleSheets !== undefined;
+          if (Array.isArray(styles)) {
+            if (supportsAdoption) {
+              (this._host as any).adoptedStyleSheets = [
+                ...(this._host as any).adoptedStyleSheets,
+                ...styles,
+              ];
+            } else {
+              console.warn(
+                "reactToWebComponent: adoptedStyleSheets unsupported; provide `styles` as a string."
+              );
+            }
+          } else {
+            const styleEl = document.createElement("style");
+            styleEl.textContent = String(styles);
+            this._host.appendChild(styleEl);
+          }
+        }
📝 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
if (styles) {
if (
Array.isArray(styles) &&
(this._host as any).adoptedStyleSheets !== undefined
) {
(this._host as any).adoptedStyleSheets = [
...(this._host as any).adoptedStyleSheets,
...styles,
];
} else {
const styleEl = document.createElement("style");
styleEl.textContent = Array.isArray(styles) ? "" : String(styles);
this._host.appendChild(styleEl);
}
}
if (styles) {
const supportsAdoption =
(this._host as any).adoptedStyleSheets !== undefined;
if (Array.isArray(styles)) {
if (supportsAdoption) {
(this._host as any).adoptedStyleSheets = [
...(this._host as any).adoptedStyleSheets,
...styles,
];
} else {
console.warn(
"reactToWebComponent: adoptedStyleSheets unsupported; provide `styles` as a string."
);
}
} else {
const styleEl = document.createElement("style");
styleEl.textContent = String(styles);
this._host.appendChild(styleEl);
}
}
🤖 Prompt for AI Agents
In ad4m-hooks/react/src/reactToWebComponent.ts around lines 116-130, the current
fallback for when styles is a CSSStyleSheet[] but the host does not support
adoptedStyleSheets creates an empty <style> element (losing all CSS); change the
logic so that if styles is an array and adoptedStyleSheets is undefined you do
NOT inject an empty style but instead emit a console.warn (or use the existing
logger) explaining that CSSStyleSheet[] cannot be applied in this environment,
and only create and append a <style> element when styles is a string (or
non-array) by setting its textContent to String(styles).

} else {
// For light DOM, set host now but defer creating children until connectedCallback
this._host = this;
}

// Only create and append mount point immediately when using shadow DOM
if (shadow) {
this._mount = document.createElement("div");
this._host.appendChild(this._mount);
this._root = ReactDOM.createRoot(this._mount);
this._isMounted = true;
}

// 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() {
// For light DOM, create mount/root on first connect to avoid constructor DOM mutations
if (!this._isMounted) {
this._mount = document.createElement("div");
this._host.appendChild(this._mount);
this._root = ReactDOM.createRoot(this._mount);
this._isMounted = true;
}
enqueueRender(this); // Kick initial render
}

disconnectedCallback() {
this._observer?.disconnect();
if (this._root) this._root.unmount();
}

// Render React with <slot/> as children (works in shadow and light DOM)
private _render() {
if (!this._root) return; // Not ready yet (light DOM before connected)
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;
}