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: [