crxjs / chrome-extension-tools

Bundling Chrome Extensions can be pretty complex. It doesn't have to be.
https://crxjs.dev/vite-plugin
2.94k stars 190 forks source link

Implementing injection of React components directly into the host DOM #820

Open geanrt opened 1 year ago

geanrt commented 1 year ago

Describe the problem

I've been studying the module and I didn't see a function to inject a React component into the page's DOM, so I developed a solution and I hope you use it and implement it in the best way!

Describe the proposed solution

we create the root (CH), rederize the component in it, wait until it is available with the Mutation Observer, take the first child node and inject it using conventional Jquery functions into the selected component (this).

utils.jsx


import React from "react";
import $ from "jquery";
import {createRoot} from "react-dom/client";

$.fn.reactAppend = async function (component) {
  return this.each(function () {
    const ch = document.createElement("div");

    const observer = new MutationObserver((mutationsList, observer) => {
      const firstChild = ch.firstChild;
      if (firstChild) {
        observer.disconnect();
        $(this).append(firstChild);
      }
    });

    observer.observe(ch, {childList: true});

    const root = createRoot(ch);
    root.render(<React.StrictMode>{component}</React.StrictMode>);
  });
};

$.fn.reactPrepend = function (component) {
  return this.each(function () {
    const ch = document.createElement("div");

    const observer = new MutationObserver((mutationsList, observer) => {
      const firstChild = ch.firstChild;
      if (firstChild) {

        observer.disconnect();
        $(this).prepend(firstChild);
      }
    });

    observer.observe(ch, {childList: true});

    const root = createRoot(ch);
    root.render(<React.StrictMode>{component}</React.StrictMode>);
  });
};

content.jsx (using)


import $ from "jquery";
import HelloWorld from "./components/HelloWorld";
import './utils';

function documentInteraction(){
  if ($("#main").length > 0 && $('[data-render="hello_world"]').length == 0) {
    $("#main").reactAppend(<HelloWorld />);
  }
}
$(document).on('click', documentInteraction)

Alternatives considered

Furthermore, I hope they bring me an update and implement this, which is very important for us who create tools and utilities for websites.

Importance

nice to have

geanrt commented 1 year ago

I also remember that I used Jquery to help me with production speed, but can you find a way without using jquery, how to identify it when we want to inject a React component into the host content and convert it somehow to an injectable block.

geanrt commented 1 year ago

This code has a problem, the component's internal functions don't work, so it's not useful... I'll try to develop a better way then.

geanrt commented 1 year ago
import React from "react";
import $ from "jquery";
import {createRoot} from "react-dom/client";

$.fn.reactAppend = async function (component) {
  return this.each(function () {
    const container = document.createElement("div");
    const root = createRoot(container);
    root.render(<React.StrictMode>{component}</React.StrictMode>);
    $(this).append(container);
  });
};

$.fn.reactPrepend = function (component) {
  return this.each(function () {
    const container = document.createElement("div");
    const root = createRoot(container);
    root.render(<React.StrictMode>{component}</React.StrictMode>);
    $(this).prepend(container);
  });
};

I made these modifications, but I intend to bring here an automatic way to inject React components into the Host

Toumash commented 6 months ago

You could just inject react on pageLoad and then in the useEffect hook into the elements on the page - thats the flow that im using in all my projects.

geanrt commented 5 months ago

You could just inject react on pageLoad and then in the useEffect hook into the elements on the page - thats the flow that im using in all my projects.

How would it be?

Toumash commented 5 months ago

Im basically using

  1. Content script that injects anywhere just to have react tree running - on the end of the body will be fine.
  2. A portal wrapper that can append itself on render into a new node or existing on rerender.
    
    import React, { useEffect, useRef, useState } from 'react';
    import { createPortal } from 'react-dom';

export default function SmartPortal({ // i didnt have a better name. Its takem probably from a stackoverflow answer id, children, append = undefined, style, }: { id: string; children: React.ReactNode; /* cannot be changed between renders / append?: (e: HTMLElement) => void; /* cannot be changed between renders / style?: Partial; }) { const el = useRef(document.getElementById(id) || document.createElement('div')); const [dynamic] = useState(!el.current.parentElement); useEffect(() => { const current = el.current; if (dynamic) { current.id = id; Object.assign(current.style, style); append !== undefined ? append(current) : document.body.appendChild(current); } return () => { if (dynamic && current.parentElement) { current.parentElement.removeChild(current); } }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [id]); return createPortal(children, el.current); }



3. Rerender a component hosting it with a MutationObserver - wait for a good host page to inject correctly.

To force update (rerender when mutationObserver detects good host page state)  - just change the id/key of the `SmartPortal`

When using this approach you do not lose the react tree (and ContextApi works)
geanrt commented 5 months ago

This is very interesting, if we add what I currently have (which works 100%, including the internal functions lol) we will have something very efficient

import React from "react";
import ReactDOM from "react-dom/client";
import $ from "jquery";

// Node `Collector` by XPath
import _x from "./_x";

interface InsertParams {
  id: string;
  path: string | HTMLElement;
  el: React.ReactNode;
  insertionMethod: "append" | "prepend" | "after" | "before";
}

class Injector {
  private insert({ id, path, el, insertionMethod }: InsertParams) {
    const container = $(`<div id="${id}"></div>`);
    if (typeof path === "string") $(_x(path))[insertionMethod](container);
    if (path instanceof HTMLElement) $(path)[insertionMethod](container);
    ReactDOM.createRoot(container[0]).render(<React.StrictMode>{el}</React.StrictMode>);
  }

  append = (id: string, path: string | HTMLElement, el: React.ReactNode) =>
    this.insert({ id, path, el, insertionMethod: "append" });
  prepend = (id: string, path: string | HTMLElement, el: React.ReactNode) =>
    this.insert({ id, path, el, insertionMethod: "prepend" });

  after = (id: string, path: string | HTMLElement, el: React.ReactNode) =>
    this.insert({ id, path, el, insertionMethod: "after" });

  before = (id: string, path: string | HTMLElement, el: React.ReactNode) =>
    this.insert({ id, path, el, insertionMethod: "before" });
}

export default new Injector();
Toumash commented 5 months ago

Yeah the Injector is implementation of the append function. In my projects where i inject extension react apps into host page react apps it is sometimes more complex thats why i didnt publish it.

Sometimes it's just insert as a specific column

import { ComponentType } from 'react';
export const XPageInjector = ({SomeRenderPropPlaceholder} : {SomeRenderPropPlaceholder:  ComponentType})=>{
return <SmartPortal
    id={`SomePlaceholder-${renderId}-${rowId}`}
    append={(e) =>document.querySelector("[class*='ClientsTable-module__headerRow'] :nth-child(3)")?.after(e); }
    style={{ /** some styles */ }}
  >
    <SomeRenderPropPlaceholder/>
</SmartPortal>
}

Sometimes it reorganizes the DOM of the host page

<SmartPortal
key={`somepage-injector-${renderId}`}
id={`somepage-injector-${renderId}`}
append={(e) => {
  let par = document.querySelector('#some-page-root');
  if (par) {
    par.prepend(e);
    return;
  }

  let someHostPageButton = getSomeButton();
  if (!someHostPageButton) {
    return;
  }
  let originalParent = someHostPageButton?.parentElement;
  let newParent = document.createElement('div');
  newParent.id = 'some-page-root';
  Object.assign(newParent.style, { display: 'flex', alignItems: 'center' });
  originalParent?.append(newParent);

  Object.assign(e, { style: 'display: flex;' });
  newParent.append(e);
  newParent.append(someHostPageButton);
}}
>
<SomeRenderPropPlaceholder/>
</SmartPortal>