diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3e937c4f17..dc97913b6c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -22,6 +22,7 @@ and this project adheres to
### Changed
+- Changed the way Monaco workers are loaded, using ESM modules instead.
- Do not include `v` param in workflow links when it is latest
[#2941](https://github.com/OpenFn/lightning/issues/2941)
diff --git a/assets/css/app.css b/assets/css/app.css
index 439ea50e3c..9a2f45c529 100644
--- a/assets/css/app.css
+++ b/assets/css/app.css
@@ -1,7 +1,7 @@
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';
-@import './monaco-style-overrides.css';
+@import '../js/monaco/styles.css';
@import 'reactflow/dist/style.css';
@import '../../deps/petal_components/assets/default.css';
/* This file is for your main application CSS */
diff --git a/assets/css/monaco-style-overrides.css b/assets/css/monaco-style-overrides.css
deleted file mode 100644
index f8d6a3d961..0000000000
--- a/assets/css/monaco-style-overrides.css
+++ /dev/null
@@ -1,28 +0,0 @@
-/* Fixes for curved borders (what a pain!)*/
-.monaco-editor,
-.monaco-editor .overflow-guard {
- background-color: transparent !important;
- @apply rounded-md;
-}
-.monaco-editor .margin {
- @apply rounded-l-md;
-}
-.monaco-editor .monaco-scrollable-element {
- @apply rounded-r-md;
- border-top-right-radius: 0.375rem;
- border-bottom-right-radius: 0.375rem;
-}
-
-/* Fix ugly right border */
-.monaco-editor .view-overlays .current-line {
- border-left: none !important;
- border-right: none !important;
-}
-
-.monaco-editor .line-numbers {
- color: #7c7c7c !important;
-}
-
-.monaco-editor .line-numbers.active-line-number {
- color: white !important;
-}
diff --git a/assets/dev-server.js b/assets/dev-server.js
index 38a2c3ae10..19e0b78f32 100644
--- a/assets/dev-server.js
+++ b/assets/dev-server.js
@@ -14,6 +14,10 @@ const ctx = await esbuild.context({
target: ['es2020'],
tsconfig: 'tsconfig.browser.json',
jsx: 'automatic',
+ loader: {
+ '.woff2': 'file',
+ '.ttf': 'copy',
+ },
plugins: [
postcss(),
copy({
diff --git a/assets/js/hooks/index.ts b/assets/js/hooks/index.ts
index 55cb6bc9da..2eea60602d 100644
--- a/assets/js/hooks/index.ts
+++ b/assets/js/hooks/index.ts
@@ -37,6 +37,8 @@ export {
CloseNodePanelViaEscape,
};
+export { ReactComponent } from '#/react/hooks';
+
export const TabIndent = {
mounted() {
this.el.addEventListener('keydown', e => {
diff --git a/assets/js/monaco/index.tsx b/assets/js/monaco/index.tsx
index ded00d6e2a..0e7dd167bb 100644
--- a/assets/js/monaco/index.tsx
+++ b/assets/js/monaco/index.tsx
@@ -1,9 +1,43 @@
import { useRef, useCallback } from 'react';
import ResizeObserver from 'rc-resize-observer';
-import Editor, { type Monaco, loader } from '@monaco-editor/react';
import type { editor } from 'monaco-editor';
+import Editor, { loader, type Monaco } from '@monaco-editor/react';
+import * as monaco from 'monaco-editor';
-loader.config({ paths: { vs: '/assets/monaco-editor/vs' } });
+// Configure Monaco with ESM workers
+// This needs to be outside of the component to ensure it's only set once
+if (typeof window !== 'undefined') {
+ // @ts-ignore - Monaco global configuration
+ window.MonacoEnvironment = {
+ getWorker(_moduleId: string, label: string) {
+ switch (label) {
+ case 'json':
+ return new Worker(new URL('json.worker.js', import.meta.url), {
+ type: 'module',
+ });
+ case 'css':
+ return new Worker(new URL('css.worker.js', import.meta.url), {
+ type: 'module',
+ });
+ case 'html':
+ return new Worker(new URL('html.worker.js', import.meta.url), {
+ type: 'module',
+ });
+ case 'typescript':
+ case 'javascript':
+ return new Worker(new URL('typescript.worker.js', import.meta.url), {
+ type: 'module',
+ });
+ default:
+ return new Worker(new URL('editor.worker.js', import.meta.url), {
+ type: 'module',
+ });
+ }
+ },
+ };
+}
+
+loader.config({ monaco });
export function setTheme(monaco: Monaco) {
monaco.editor.defineTheme('default', {
@@ -36,7 +70,7 @@ export const MonacoEditor = ({
const handleOnMount = useCallback((editor: any, monaco: Monaco) => {
monacoRef.current = monaco;
editorRef.current = editor;
- if (!props.options.enableCommandPalette) {
+ if (!props['options']?.enableCommandPalette) {
const ctxKey = editor.createContextKey('command-palette-override', true);
editor.addCommand(
diff --git a/assets/js/monaco/styles.css b/assets/js/monaco/styles.css
index e69de29bb2..3c3435c76d 100644
--- a/assets/js/monaco/styles.css
+++ b/assets/js/monaco/styles.css
@@ -0,0 +1,30 @@
+@import 'monaco-editor/min/vs/editor/editor.main.css';
+
+/* Fixes for curved borders (what a pain!)*/
+.monaco-editor,
+.monaco-editor .overflow-guard {
+ background-color: transparent !important;
+ @apply rounded-md;
+}
+.monaco-editor .margin {
+ @apply rounded-l-md;
+}
+.monaco-editor .monaco-scrollable-element {
+ @apply rounded-r-md;
+ border-top-right-radius: 0.375rem;
+ border-bottom-right-radius: 0.375rem;
+}
+
+/* Fix ugly right border */
+.monaco-editor .view-overlays .current-line {
+ border-left: none !important;
+ border-right: none !important;
+}
+
+.monaco-editor .line-numbers {
+ color: #7c7c7c !important;
+}
+
+.monaco-editor .line-numbers.active-line-number {
+ color: white !important;
+}
\ No newline at end of file
diff --git a/assets/js/react/components/Bar.tsx b/assets/js/react/components/Bar.tsx
new file mode 100644
index 0000000000..1fd5c872b0
--- /dev/null
+++ b/assets/js/react/components/Bar.tsx
@@ -0,0 +1,14 @@
+export type BarProps = {
+ before: React.ReactNode;
+ after: React.ReactNode;
+ children: React.ReactNode;
+};
+
+export const Bar = ({ before, after, children }: BarProps) => (
+ <>
+ {before}
+
Bar:
+ {children}
+ {after}
+ >
+);
diff --git a/assets/js/react/components/Baz.tsx b/assets/js/react/components/Baz.tsx
new file mode 100644
index 0000000000..9ded9ac048
--- /dev/null
+++ b/assets/js/react/components/Baz.tsx
@@ -0,0 +1,11 @@
+export type BazProps = {
+ baz: number;
+ children: React.ReactNode;
+};
+
+export const Baz = ({ baz, children }: BazProps) => (
+ <>
+ Baz: {baz}
+ {children}
+ >
+);
diff --git a/assets/js/react/components/Boundary.tsx b/assets/js/react/components/Boundary.tsx
new file mode 100644
index 0000000000..d02a36a786
--- /dev/null
+++ b/assets/js/react/components/Boundary.tsx
@@ -0,0 +1,33 @@
+import React, { Suspense } from 'react';
+
+import { ErrorBoundary } from 'react-error-boundary';
+
+import { Fallback } from './Fallback';
+
+export type BoundaryProps = {
+ children?: React.ReactNode;
+};
+
+/**
+ * Some best practices:
+ *
+ * - Catch rendering errors and recover gracefully with an [error boundary](https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary)
+ * TODO: Configure [`ErrorBoundary`](https://github.com/bvaughn/react-error-boundary#readme)
+ * - Use a [suspense boundary](https://react.dev/reference/react/Suspense) to allow suspense features (such as `React.lazy`) to work.
+ * TODO: show a loading ("fallback") UI?
+ */
+export const Boundary = React.forwardRef(
+ function Boundary({ children }, ref) {
+ return (
+
+
+
+ {children}
+
+
+
+ );
+ }
+);
+// ESBuild renames the inner component to `Boundary2`
+Boundary.displayName = 'Boundary';
diff --git a/assets/js/react/components/Fallback.tsx b/assets/js/react/components/Fallback.tsx
new file mode 100644
index 0000000000..060c54fe80
--- /dev/null
+++ b/assets/js/react/components/Fallback.tsx
@@ -0,0 +1,12 @@
+import type { FallbackProps } from 'react-error-boundary';
+
+export type { FallbackProps };
+
+export const Fallback = ({ error }: FallbackProps) => (
+
+
Something went wrong:
+
+ {error instanceof Error ? error.message : String(error)}
+
+
+);
diff --git a/assets/js/react/components/Foo.tsx b/assets/js/react/components/Foo.tsx
new file mode 100644
index 0000000000..ffe413edeb
--- /dev/null
+++ b/assets/js/react/components/Foo.tsx
@@ -0,0 +1,11 @@
+export type FooProps = {
+ foo: number;
+ children: React.ReactNode;
+};
+
+export const Foo = ({ foo, children }: FooProps) => (
+ <>
+ Foo: {foo}
+ {children}
+ >
+);
diff --git a/assets/js/react/components/Slot.tsx b/assets/js/react/components/Slot.tsx
new file mode 100644
index 0000000000..f24d3c278c
--- /dev/null
+++ b/assets/js/react/components/Slot.tsx
@@ -0,0 +1,55 @@
+import type { View } from 'phoenix_live_view';
+
+import { useLayoutEffect, useRef, useState } from 'react';
+
+import { performPatch } from '#/react/lib';
+
+const style: React.CSSProperties = {
+ // Don't create a box for this element
+ // https://developer.mozilla.org/en-US/docs/Web/CSS/display#contents
+ display: 'contents',
+};
+
+export type SlotProps = {
+ view: View;
+ name: string;
+ html: string;
+ cID?: number | null;
+};
+
+export const Slot = ({ view, name, html, cID = null }: SlotProps) => {
+ const [el, setEl] = useState(null);
+ const unpatchedRef = useRef(true);
+
+ // Intentionally referentially stable value, should never change so that React
+ // never re-renders the child. We want Phoenix to do it for us instead.
+ const [innerHTML] = useState(() => ({ __html: html }));
+
+ useLayoutEffect(() => {
+ if (
+ el == null ||
+ !el.isConnected ||
+ (innerHTML.__html === html && unpatchedRef.current)
+ ) {
+ return;
+ }
+
+ performPatch(
+ view,
+ el,
+ `${html}
`,
+ cID
+ );
+
+ unpatchedRef.current = false;
+ }, [name, el, innerHTML.__html, view, html, cID]);
+
+ return useState(() => (
+
+ ))[0];
+};
diff --git a/assets/js/react/components/index.ts b/assets/js/react/components/index.ts
new file mode 100644
index 0000000000..63610baa06
--- /dev/null
+++ b/assets/js/react/components/index.ts
@@ -0,0 +1,7 @@
+export { Foo, type FooProps } from './Foo';
+export { Bar, type BarProps } from './Bar';
+export { Baz, type BazProps } from './Baz';
+
+export { Fallback, type FallbackProps } from './Fallback';
+export { Boundary, type BoundaryProps } from './Boundary';
+export { Slot, type SlotProps } from './Slot';
diff --git a/assets/js/react/hooks/index.ts b/assets/js/react/hooks/index.ts
new file mode 100644
index 0000000000..6967598d3d
--- /dev/null
+++ b/assets/js/react/hooks/index.ts
@@ -0,0 +1 @@
+export { ReactComponent } from './react-component';
diff --git a/assets/js/react/hooks/react-component.tsx b/assets/js/react/hooks/react-component.tsx
new file mode 100644
index 0000000000..9ebed79489
--- /dev/null
+++ b/assets/js/react/hooks/react-component.tsx
@@ -0,0 +1,337 @@
+import invariant from 'tiny-invariant';
+import warning from 'tiny-warning';
+
+import ReactDOMClient from 'react-dom/client';
+
+import {
+ getClosestReactContainerElement,
+ importComponent,
+ isReactContainerElement,
+ isReactHookedElement,
+ lazyLoadComponent,
+ renderSlots,
+ replaceEqualDeep,
+ withProps,
+} from '#/react/lib';
+
+import { Boundary } from '#/react/components';
+
+import type { ReactComponentHook } from '#/react/types';
+import { StrictMode } from 'react';
+import type { ViewHook } from 'phoenix_live_view';
+
+export const ReactComponent = {
+ mounted() {
+ invariant(
+ isReactHookedElement(this.el),
+ this.errorMsg('Element is not valid for this hook!')
+ );
+ invariant(
+ isReactContainerElement(this.el.nextElementSibling) &&
+ this.el.nextElementSibling.dataset.reactContainer === this.el.id,
+ this.errorMsg(`Missing valid React container element!`)
+ );
+
+ this.onBoundary = this.onBoundary.bind(this);
+ this.subscribe = this.subscribe.bind(this);
+ this.getProps = this.getProps.bind(this);
+ this.getPortals = this.getPortals.bind(this);
+ this.portals = new Map();
+
+ this.liveSocket.owner(this.el, view => {
+ this.view = view;
+ });
+ this.name = this.el.dataset.reactName;
+ this.file = this.el.dataset.reactFile;
+ this.containerEl = this.el.nextElementSibling;
+ this.Component = withProps(
+ lazyLoadComponent(() => importComponent(this.file, this.name), this.name),
+ /* eslint-disable @typescript-eslint/unbound-method -- bound using `Function.prototype.bind` */
+ this.subscribe,
+ this.getProps,
+ this.getPortals,
+ /* eslint-enable */
+ this.view,
+ this.view.componentID(this.el)
+ );
+ this.listeners = new Set();
+ this.boundaryMounted = false;
+ this.mount();
+ },
+
+ updated() {
+ this.setProps();
+ },
+
+ beforeDestroy() {
+ this.unmount();
+ },
+
+ destroyed() {
+ window.addEventListener(
+ 'phx:page-loading-stop',
+ () => {
+ this.unmount();
+ },
+ {
+ once: true,
+ }
+ );
+ },
+
+ subscribe(onPropsChange: () => void): () => void {
+ this.listeners.add(onPropsChange);
+
+ const unsubscribe = () => {
+ this.listeners.delete(onPropsChange);
+ };
+
+ return unsubscribe;
+ },
+
+ getProps() {
+ invariant(this.props !== undefined, this.errorMsg('Uninitialized props!'));
+
+ return this.props;
+ },
+
+ addPortal(
+ children: React.ReactNode,
+ container: Element | DocumentFragment,
+ key: string
+ ) {
+ warning(
+ !this.portals.has(key),
+ this.errorMsg('Portal has already been added! Overwriting!')
+ );
+
+ // Immutably update the map
+ // See https://zustand.docs.pmnd.rs/guides/maps-and-sets-usage#map-and-set-usage
+ this.portals = new Map(this.portals).set(key, [container, children]);
+
+ this.rerender();
+ },
+
+ removePortal(key: string) {
+ warning(
+ this.portals.has(key),
+ this.errorMsg('Cannot remove missing portal!')
+ );
+
+ // Immutably update the map
+ // See https://zustand.docs.pmnd.rs/guides/maps-and-sets-usage#map-and-set-usage
+ this.portals = new Map(this.portals);
+ this.portals.delete(key);
+
+ this.rerender();
+ },
+
+ getPortals() {
+ return this.portals;
+ },
+
+ onBoundary(element: HTMLDivElement | null) {
+ this.view.liveSocket.requestDOMUpdate(() => {
+ if (element == null || !element.isConnected || this.boundaryMounted) {
+ return;
+ }
+ this.view.execNewMounted();
+ this.boundaryMounted = true;
+ });
+ },
+
+ setProps() {
+ invariant(
+ this.el.textContent != null && this.el.textContent !== '',
+ this.errorMsg('No content in
+
+ """
+ end
+ end
+ else
+ message = """
+ could not read template "#{relative_file}": no such file or directory. \
+ Trying to read file "#{file}".
+ """
+
+ IOHelper.compile_error(message, __CALLER__.file, __CALLER__.line)
+ end
+ end
+
+ def component_id(name) do
+ # a small trick to avoid collisions of IDs but keep them consistent
+ # across dead and live renders
+ # id(name) is called only once during the whole LiveView lifecycle
+ # because it's not using any assigns
+ number = Process.get(:react_counter, 1)
+ Process.put(:react_counter, number + 1)
+ "#{name}-#{number}"
+ end
+
+ def json(data),
+ do:
+ Phoenix.json_library().encode!(
+ Map.reject(data, fn {key, _val} ->
+ String.starts_with?(to_string(key), "__")
+ end),
+ escape: :html_safe
+ )
+end
diff --git a/lib/react/compile_error.ex b/lib/react/compile_error.ex
new file mode 100644
index 0000000000..c816c9bcce
--- /dev/null
+++ b/lib/react/compile_error.ex
@@ -0,0 +1,98 @@
+defmodule React.CompileError do
+ @moduledoc """
+ An exception raised when there's a React compile error.
+
+ The following fields of this exceptions are public and can be accessed freely:
+
+ * `:file` (`t:Path.t/0` or `nil`) - the file where the error occurred, or `nil` if
+ the error occurred in code that did not come from a file
+ * `:line` - the line where the error occurred
+ * `:column` - the column where the error occurred
+ * `:description` - a description of the error
+ * `:hint` - a hint to help the user to fix the issue
+
+ """
+
+ @support_snippet Version.match?(System.version(), ">= 1.17.0")
+
+ defexception [
+ :file,
+ :line,
+ :column,
+ :snippet,
+ :hint,
+ description: "compile error"
+ ]
+
+ @impl true
+ def message(%{
+ file: file,
+ line: line,
+ column: column,
+ description: description,
+ hint: hint
+ }) do
+ format_message(file, line, column, description, hint)
+ end
+
+ if @support_snippet do
+ defp format_message(file, line, column, description, hint) do
+ message =
+ if File.exists?(file) do
+ {lineCode, _} =
+ File.stream!(file) |> Stream.with_index() |> Enum.at(line - 1)
+
+ lineCode = String.trim_trailing(lineCode)
+
+ :elixir_errors.format_snippet(
+ :error,
+ {line, column},
+ file,
+ description,
+ lineCode,
+ %{}
+ )
+ else
+ prefix = IO.ANSI.format([:red, "error:"])
+ "#{prefix} #{description}"
+ end
+
+ hint =
+ if hint do
+ "\n\n" <>
+ :elixir_errors.format_snippet(:hint, nil, nil, hint, nil, %{})
+ else
+ ""
+ end
+
+ location =
+ Exception.format_file_line_column(
+ Path.relative_to_cwd(file),
+ line,
+ column
+ )
+
+ location <> "\n" <> message <> hint
+ end
+ else
+ defp format_message(file, line, column, description, hint) do
+ location =
+ Exception.format_file_line_column(
+ Path.relative_to_cwd(file),
+ line,
+ column
+ )
+
+ hint =
+ if hint do
+ prefix = IO.ANSI.format([:blue, "hint:"])
+ "\n\n#{prefix} " <> hint
+ else
+ ""
+ end
+
+ prefix = IO.ANSI.format([:red, "error:"])
+ location <> "\n#{prefix} " <> description <> hint
+ end
+ end
+end
diff --git a/lib/react/component.ex b/lib/react/component.ex
new file mode 100644
index 0000000000..9f68b1d8f0
--- /dev/null
+++ b/lib/react/component.ex
@@ -0,0 +1,24 @@
+defmodule React.Component do
+ @moduledoc """
+ Defines a stateless component.
+
+ ## Example
+
+ defmodule Button do
+ use React.Component
+
+ jsx "react/Button.tsx"
+ end
+
+ > **Note**: Stateless components cannot handle Phoenix LiveView events.
+ If you need to handle them, please use a `React.LiveComponent` instead (not
+ currently implemented).
+ """
+
+ @doc false
+ defmacro __using__(_opts) do
+ quote do
+ import React
+ end
+ end
+end
diff --git a/lib/react/io_helper.ex b/lib/react/io_helper.ex
new file mode 100644
index 0000000000..f7bf4851d5
--- /dev/null
+++ b/lib/react/io_helper.ex
@@ -0,0 +1,85 @@
+defmodule React.IOHelper do
+ @moduledoc false
+
+ def warn(message, caller) do
+ stacktrace = Macro.Env.stacktrace(caller)
+
+ IO.warn(message, stacktrace)
+ end
+
+ def warn(message, caller, line) do
+ stacktrace = Macro.Env.stacktrace(%{caller | line: line})
+
+ IO.warn(message, stacktrace)
+ end
+
+ def warn(message, _caller, file, {line, column}) do
+ IO.warn(message, file: file, line: line, column: column)
+ end
+
+ def warn(message, caller, file, line) do
+ stacktrace = Macro.Env.stacktrace(%{caller | file: file, line: line})
+
+ IO.warn(message, stacktrace)
+ end
+
+ def compile_error(message, file, {line, column}) do
+ reraise(
+ %React.CompileError{
+ file: file,
+ line: line,
+ column: column,
+ description: message
+ },
+ []
+ )
+ end
+
+ def compile_error(message, file, line) do
+ reraise(
+ %React.CompileError{line: line, file: file, description: message},
+ []
+ )
+ end
+
+ def compile_error(message, hint, file, {line, column}) do
+ reraise(
+ %React.CompileError{
+ file: file,
+ line: line,
+ column: column,
+ description: message,
+ hint: hint
+ },
+ []
+ )
+ end
+
+ def compile_error(message, hint, file, line) do
+ reraise(
+ %React.CompileError{
+ line: line,
+ file: file,
+ description: message,
+ hint: hint
+ },
+ []
+ )
+ end
+
+ @spec syntax_error(String.t(), String.t(), integer()) :: no_return()
+ def syntax_error(message, file, line) do
+ reraise(%SyntaxError{line: line, file: file, description: message}, [])
+ end
+
+ @spec runtime_error(String.t()) :: no_return()
+ def runtime_error(message) do
+ stacktrace =
+ self()
+ |> Process.info(:current_stacktrace)
+ |> elem(1)
+ |> Enum.drop(2)
+
+ reraise(message, stacktrace)
+ end
+end
diff --git a/lib/react/slots.ex b/lib/react/slots.ex
new file mode 100644
index 0000000000..0234beb782
--- /dev/null
+++ b/lib/react/slots.ex
@@ -0,0 +1,43 @@
+defmodule React.Slots do
+ @moduledoc false
+
+ import Phoenix.Component
+
+ @doc false
+ def render_slots(assigns) do
+ for(
+ attr <- assigns,
+ into: %{},
+ do:
+ case attr do
+ {key, [%{__slot__: _}] = slot} ->
+ {if key == :inner_block do
+ :children
+ else
+ key
+ end,
+ %{
+ __type__: "__slot__",
+ data:
+ render(%{slot: slot})
+ |> Phoenix.HTML.Safe.to_iodata()
+ |> List.to_string()
+ |> String.trim()
+ |> Base.encode64()
+ }}
+
+ _ ->
+ attr
+ end
+ )
+ end
+
+ @doc false
+ defp render(assigns) do
+ ~H"""
+ <%= if assigns[:slot] do %>
+ <%= render_slot(@slot) %>
+ <% end %>
+ """
+ end
+end
diff --git a/mix.exs b/mix.exs
index fb61945863..962e738802 100644
--- a/mix.exs
+++ b/mix.exs
@@ -27,6 +27,7 @@ defmodule Lightning.MixProject do
coveralls: :test,
verify: :test
],
+ compilers: Mix.compilers(),
# Docs
name: "Lightning",
@@ -180,7 +181,7 @@ defmodule Lightning.MixProject do
"assets.deploy": [
"tailwind default --minify",
"esbuild default --minify",
- "esbuild monaco --minify",
+ "esbuild react --minify",
"phx.digest"
],
verify: [