Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create Alakazam Live Component #71

Open
5 of 14 tasks
arthur-fontaine opened this issue Nov 12, 2023 · 0 comments
Open
5 of 14 tasks

Create Alakazam Live Component #71

arthur-fontaine opened this issue Nov 12, 2023 · 0 comments
Assignees
Labels
alakazam This issue or pull request relates to Alakazam enhancement New feature or request
Milestone

Comments

@arthur-fontaine
Copy link
Contributor

arthur-fontaine commented Nov 12, 2023

Description

Alakazam Live Components should generate components (in Kaz AST format) that will fetch the component, and then transform the component to targets (using Kazam).

Tasks

  • Create the Preact+htm transformer
    Use it to render the component at client side because it is very lightweight (3kb + 0.5kb).
  • Implement an alakazam option in the transformer service that will allow to download the "Alakazam Live Component"
  • Create the Alakazam Live Component API endpoints
    It is in charge to return a script that will render the component at client-side.
  • Add a button to download the Alakazam Live Components

EDIT

  • Create the service worker
  • Convert the get-live-component endpoint to GET to allow caching
  • Use two fetch in the Alakazam Client component (the first one with cache: "force-cache" (awaited) and the second one with cache: "reload" (not awaited))
  • Evaluate if it is better to use esbuild with the HTTP plugin (https://esbuild.github.io/plugins/#http-plugin) to prebuild Preact and HTM, instead of making additional requests but cached

EDIT 2

Currently, the returned Alakazam client component for React is something like this:

import * as React from "react";
import { useEffect } from "react";

export default () => {
  React.useLayoutEffect(() => {
    const componentSelector = `alakazam-component#_ymuy8OGnN1ax5eDEYjW4r8ASnnQDsqXfZwurTqbiuu8`;
    const component = document.querySelector(componentSelector);

    if (component === null) {
      throw new Error(`Alakazam component with ID "_ymuy8OGnN1ax5eDEYjW4r8ASnnQDsqXfZwurTqbiuu8" not found`);
    }

    const ALAKAZAM_PROPS_KEY = "_alakazamProps";

    window[ALAKAZAM_PROPS_KEY] ??= [];
    window[ALAKAZAM_PROPS_KEY]["_ymuy8OGnN1ax5eDEYjW4r8ASnnQDsqXfZwurTqbiuu8"] = {};

    const fetchComponent = async (componentKey: string, propsAccessor: string, selector: string, signal: AbortSignal) => {
      const componentUrl = new URL("http://localhost:3000");
      componentUrl.pathname = "/api/get-live-component";
      componentUrl.searchParams.set("componentKey", componentKey);
      componentUrl.searchParams.set("propsAccessor", propsAccessor);
      componentUrl.searchParams.set("selector", selector);
      const fetchParams = {
        priority: "high",
        method: "GET",
        signal,
        headers: {
          "Content-Type": "application/json",
        },
      };

      const response = await fetch(componentUrl.toString(), {
        ...fetchParams,
        cache: "force-cache"
      });
      fetch(componentUrl.toString(), {
        ...fetchParams,
        cache: "reload"
      });

      return await response.text();
    };

    // TODO: use AbortController to abort fetch if component is unmounted
    const abortController = new AbortController();

    fetchComponent("eyJjb21wb25lbnRQYXRoIjoiL2NvbXBvbmVudHMvSW5wdXQua2F6IiwicHJvamVjdElkIjoiNWQwMTQ2OWMtYTQ1Mi0xMWVlLTkwNmYtNjMyMGYxNmJiODYyIn0=", `window[${JSON.stringify(ALAKAZAM_PROPS_KEY)}]["_ymuy8OGnN1ax5eDEYjW4r8ASnnQDsqXfZwurTqbiuu8"]`, componentSelector, abortController.signal).then((preactSource) => {
      const script = document.createElement("script");
      script.type = "module";
      script.innerHTML = preactSource;
      component.appendChild(script);
    });
  }, []);
  return (
    <>
      <alakazam-component style={{ cssText: "display: contents" }} id="_ymuy8OGnN1ax5eDEYjW4r8ASnnQDsqXfZwurTqbiuu8"></alakazam-component>
    </>
  );
};

We can optimize it by transforming it to something like this:

import * as React from "react";

const componentSelector = `alakazam-component#_ymuy8OGnN1ax5eDEYjW4r8ASnnQDsqXfZwurTqbiuu8`;

const ALAKAZAM_PROPS_KEY = "_alakazamProps";

window[ALAKAZAM_PROPS_KEY] ??= [];
window[ALAKAZAM_PROPS_KEY]["_ymuy8OGnN1ax5eDEYjW4r8ASnnQDsqXfZwurTqbiuu8"] = {};

const fetchComponent = async (componentKey: string, propsAccessor: string, selector: string) => {
  const componentUrl = new URL("http://localhost:3000");
  componentUrl.pathname = "/api/get-live-component";
  componentUrl.searchParams.set("componentKey", componentKey);
  componentUrl.searchParams.set("propsAccessor", propsAccessor);
  componentUrl.searchParams.set("selector", selector);
  const fetchParams = {
    priority: "high",
    method: "GET",
    headers: {
      "Content-Type": "application/json",
    },
  };

  const response = await fetch(componentUrl.toString(), {
    ...fetchParams,
    cache: "force-cache"
  });
  fetch(componentUrl.toString(), {
    ...fetchParams,
    cache: "reload"
  });

  return await response.text();
};

const cp = fetchComponent("eyJjb21wb25lbnRQYXRoIjoiL2NvbXBvbmVudHMvSW5wdXQua2F6IiwicHJvamVjdElkIjoiNWQwMTQ2OWMtYTQ1Mi0xMWVlLTkwNmYtNjMyMGYxNmJiODYyIn0=", `window[${JSON.stringify(ALAKAZAM_PROPS_KEY)}]["_ymuy8OGnN1ax5eDEYjW4r8ASnnQDsqXfZwurTqbiuu8"]`, componentSelector)

export default () => {
  return (
    <>
      <alakazam-component
      ref={(e) => {
        if (e === null) {
          return;
        }

        cp.then((preactSource) => {
          const script = document.createElement("script");
          script.type = "module";
          script.innerHTML = preactSource;
          e.appendChild(script);
        });
      }}
      style={{ cssText: "display: contents" }} id="_ymuy8OGnN1ax5eDEYjW4r8ASnnQDsqXfZwurTqbiuu8"></alakazam-component>
    </>
  );
};

To do that, we need to:

  • Create on:created argument (not instruction), allowing to use ref in React
  • Create const instruction which allow to define variables outside of the component
  • Use the const instruction to fetch the Alakazam component
  • Use the on:created argument to add the Alakazam component

EDIT 3

  • We may want to evaluate a function that takes a DOM element as parameter to use to render, instead of creating a script, adding this script to the DOM, search for an element with the corresponding id, etc.
  • Remove the script.type = "module"; to make the script priority higher (reference) (the component blinks 9 times over 10 with module, 2 times over 10 without)

Examples

Example 1

(see the PoC working on Glitch: https://glitch.com/edit/#!/jagged-magic-cheetah)

This is the component generated by Alakazam Live Component:

import React, { useRef, useEffect } from 'react'

const Component = ({ text }: { text: string }) => {
  const elementRef = useRef<HTMLDivElement>(null)

  useEffect(() => {
    if (!elementRef.current) {
      return
    }

    const abortController = new AbortController()

    await fetch(
      `https://alakazam.io/project-id/component-id?${new URLSearchParams({ text })}`,
      { signal },
    )
      .then((response) => response.text())
      .then((liveComponentSource) => {
        const liveComponent = new DOMParser().parseFromString(
          liveComponentSource,
          "text/html"
        ).body.firstChild as HTMLDivElement;

        elementRef.current.replaceWith(liveComponent);
        
        // Force running scripts
        liveComponent.querySelectorAll("script").forEach((script) => {
          const newScript = document.createElement("script");
          script.getAttributeNames().forEach((name) => {
            newScript.setAttribute(name, script.getAttribute(name)!);
          });
          newScript.innerHTML = script.innerHTML;

          liveComponent.removeChild(script);
          liveComponent.appendChild(newScript);
        });
      })

    return () => {
      abortController.abort()
    }
  }, [elementRef])

  return <alakazam-component ref={elementRef} />
}

This is a PoC of an implementation of the API endpoint:

export default async function (parameters: Record<string, string>) => {
  const id = Math.random().toString(36).substring(2, 9);

  // TODO: replace `const Component = ...` by the component generated by Kazam (with the `Preact`+`htm` transformer)
  return `
    <alakazam-component style="display: contents;" uid="${id}">
      <script type="module">
        import { h, render } from 'https://esm.sh/preact';
        import { useState, useCallback } from 'https://esm.sh/preact/hooks';
        import htm from 'https://esm.sh/htm/mini';

        // Initialize htm with Preact
        const html = htm.bind(h);

        const Component = (props) => {
          const [state, setState] = useState(0);

          const increment = useCallback(() => {
            console.log('increment')
            setState((state) => state + 1)
          }, [setState]);

          const decrement = useCallback(() => {
            setState((state) => state - 1)
          }, [setState]);

          return html\`<h1>
            \${props.text}
            \${state}
            <button onClick=\${increment}>+</button>
            <button onClick=\${decrement}>-</button>
          </h1>\`;
        }

        render(h(() => Component(${JSON.stringify(
          parameters
        )})), document.querySelector('alakazam-component[uid="${id}"]'));
      </script>
    </alakazam-component>
  `;
}

Example 2

(see the PoC working on Glitch: https://glitch.com/edit/#!/ebony-remarkable-cymbal)

This version returns only a script as string.

Example 3

(see the PoC working on Glitch: https://glitch.com/edit/#!/catnip-fossil-geometry)

This version may be the most stable version, as it does not send the props via the fetch function. The props stay in a global variable, and this global variable is accessed locally by the component to render.

@arthur-fontaine arthur-fontaine self-assigned this Nov 12, 2023
@arthur-fontaine arthur-fontaine added this to the Alakazam PoC milestone Nov 12, 2023
@arthur-fontaine arthur-fontaine added enhancement New feature or request alakazam This issue or pull request relates to Alakazam labels Nov 12, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
alakazam This issue or pull request relates to Alakazam enhancement New feature or request
Projects
None yet
Development

When branches are created from issues, their pull requests are automatically linked.

1 participant