├── .formatter.exs ├── .github ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── tests.yml ├── .gitignore ├── .prettierignore ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── assets ├── copy │ ├── js │ │ └── server.js │ ├── package.json │ ├── postcss.config.js │ ├── react-components │ │ ├── index.js │ │ └── simple.jsx │ ├── tsconfig.json │ └── vite.config.js └── js │ └── live_react │ ├── context.jsx │ ├── hooks.js │ ├── index.d.mts │ ├── index.mjs │ ├── server.mjs │ ├── utils.js │ └── vite-plugin.js ├── config └── config.exs ├── guides ├── deployment.md ├── development.md ├── installation.md └── ssr.md ├── lib ├── live_react.ex ├── live_react │ ├── reload.ex │ ├── slots.ex │ ├── ssr.ex │ ├── ssr │ │ ├── node_js.ex │ │ └── vite_js.ex │ └── test.ex └── mix │ └── tasks │ └── setup.ex ├── live_react_examples ├── .dockerignore ├── .formatter.exs ├── .gitignore ├── .prettierignore ├── Dockerfile ├── README.md ├── assets │ ├── css │ │ └── app.css │ ├── js │ │ ├── app.js │ │ └── server.js │ ├── package-lock.json │ ├── package.json │ ├── postcss.config.js │ ├── react-components │ │ ├── components │ │ │ └── lazy-component.jsx │ │ ├── context.tsx │ │ ├── counter.jsx │ │ ├── delay-slider.tsx │ │ ├── flash-sonner.jsx │ │ ├── github-code.jsx │ │ ├── index.jsx │ │ ├── lazy.jsx │ │ ├── lib │ │ │ └── utils.ts │ │ ├── log-list.jsx │ │ ├── simple-props.jsx │ │ ├── simple.jsx │ │ ├── slot.tsx │ │ ├── ssr.jsx │ │ ├── typescript.tsx │ │ └── ui │ │ │ ├── dual-range-slider.tsx │ │ │ └── label.tsx │ ├── tailwind.config.js │ ├── tsconfig.json │ └── vite.config.js ├── config │ ├── config.exs │ ├── dev.exs │ ├── prod.exs │ ├── runtime.exs │ └── test.exs ├── fly.toml ├── lib │ ├── live_react_examples.ex │ ├── live_react_examples │ │ ├── application.ex │ │ └── telemetry.ex │ ├── live_react_examples_web.ex │ └── live_react_examples_web │ │ ├── components │ │ ├── core_components.ex │ │ ├── layouts.ex │ │ └── layouts │ │ │ ├── app.html.heex │ │ │ └── root.html.heex │ │ ├── controllers │ │ ├── error_html.ex │ │ ├── error_json.ex │ │ ├── page_controller.ex │ │ ├── page_html.ex │ │ └── page_html │ │ │ ├── home.html.heex │ │ │ ├── lazy.html.heex │ │ │ ├── simple.html.heex │ │ │ ├── simple_props.html.heex │ │ │ └── typescript.html.heex │ │ ├── endpoint.ex │ │ ├── live │ │ ├── context.ex │ │ ├── counter.ex │ │ ├── demo_assigns.ex │ │ ├── flash_sonner.ex │ │ ├── hybrid_form.ex │ │ ├── log_list.ex │ │ ├── slot.ex │ │ └── ssr.ex │ │ ├── router.ex │ │ └── telemetry.ex ├── mix.exs ├── mix.lock ├── priv │ └── static │ │ ├── favicon.ico │ │ ├── images │ │ └── logo.svg │ │ └── robots.txt ├── rel │ ├── env.sh.eex │ └── overlays │ │ └── bin │ │ ├── server │ │ └── server.bat └── test │ ├── live_react_examples_web │ └── controllers │ │ ├── error_html_test.exs │ │ ├── error_json_test.exs │ │ └── page_controller_test.exs │ ├── support │ └── conn_case.ex │ └── test_helper.exs ├── logo.svg ├── mix.exs ├── mix.lock ├── package-lock.json ├── package.json └── test ├── live_react_test.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Contributor checklist 2 | 3 | - [ ] My commit messages follow the [Conventional Commit Message Format](https://gist.github.com/stephenparish/9941e89d80e2bc58a153#format-of-the-commit-message) 4 | For example: `fix: Multiply by appropriate coefficient`, or 5 | `feat(Calculator): Correctly preserve history` 6 | Any explanation or long form information in your commit message should be 7 | in a separate paragraph, separated by a blank line from the primary message 8 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Tests 3 | 4 | on: 5 | push: 6 | branches: 7 | - main 8 | pull_request: 9 | branches: 10 | - main 11 | 12 | env: 13 | MIX_ENV: test 14 | 15 | jobs: 16 | code_quality_and_tests: 17 | runs-on: ubuntu-latest 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | include: 22 | - elixir: 1.18.1 23 | erlang: 27.2.0 24 | name: Elixir v${{ matrix.elixir }}, Erlang v${{ matrix.erlang }} 25 | steps: 26 | - uses: actions/checkout@v4 27 | 28 | - uses: erlef/setup-beam@v1 29 | with: 30 | otp-version: ${{ matrix.erlang }} 31 | elixir-version: ${{ matrix.elixir }} 32 | 33 | - name: Retrieve Dependencies Cache 34 | uses: actions/cache@v4 35 | id: mix-cache 36 | with: 37 | path: | 38 | deps 39 | _build 40 | key: ${{ runner.os }}-${{ matrix.erlang }}-${{ matrix.elixir }}-mix-${{ hashFiles('**/mix.lock') }} 41 | 42 | - name: Install Mix Dependencies 43 | run: mix deps.get 44 | 45 | - name: Check unused dependencies 46 | run: mix deps.unlock --check-unused 47 | 48 | - name: Compile dependencies 49 | run: mix deps.compile 50 | 51 | - name: Check format 52 | run: mix format --check-formatted 53 | 54 | - name: Check application compile warnings 55 | run: mix compile --force --warnings-as-errors 56 | 57 | - name: Check Credo warnings 58 | run: mix credo 59 | 60 | - name: Run tests 61 | run: mix test 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | live_react-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | 28 | # Ignore node_modules 29 | /node_modules/ 30 | 31 | # LSP elixir 32 | .elixir_ls/ 33 | .elixir-tools/ 34 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | priv/ 2 | live_react_examples/deps/ 3 | deps/ 4 | doc/ 5 | .elixir_ls 6 | CHANGELOG.md 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | See [Conventional Commits](Https://conventionalcommits.org) for commit guidelines. 5 | 6 | 7 | 8 | ## [v1.0.1](https://github.com/mrdotb/live_react/compare/v1.0.1...v1.0.0) (2025-04-20) 9 | 10 | ### Bug Fixes: 11 | 12 | * add missing useLiveReact type 13 | 14 | 15 | ## [v1.0.0](https://github.com/mrdotb/live_react/compare/v1.0.0...v1.0.0-rc.4) (2025-03-10) 16 | 17 | ### Breaking Changes: 18 | 19 | * vitejs: switch from Mix Esbuild to Vite.js 20 | 21 | ### Features: 22 | 23 | * add tests based on the one from live_vue 24 | * add SSR support 25 | * support inner_block slot 26 | * context provider for live_react 27 | * add typescript support 28 | 29 | 30 | ## [v1.0.0-rc.4](https://github.com/mrdotb/live_react/compare/v1.0.0-rc.3...v1.0.0-rc.4) (2025-01-22) 31 | 32 | ### Features: 33 | 34 | * add tests based on the one from live_vue 35 | 36 | ### Bug Fixes: 37 | 38 | * Ensure app.ts entrypoints can be used with @react-refresh 39 | 40 | ## [v1.0.0-rc.3](https://github.com/mrdotb/live_react/compare/v1.0.0-rc.2...v1.0.0-rc.3) (2024-12-08) 41 | 42 | 43 | ### Features: 44 | 45 | * support inner_block slot 46 | 47 | ## [v1.0.0-rc.2](https://github.com/mrdotb/live_react/compare/v1.0.0-rc.1...v1.0.0-rc.2) (2024-12-01) 48 | 49 | 50 | 51 | 52 | ### Features: 53 | 54 | * Added SSR duration logging to example app 55 | 56 | ### Bug Fixes: 57 | 58 | * rename react folder to react-components to prevent Vite error 59 | 60 | ## [v1.0.0-rc.1](https://github.com/mrdotb/live_react/compare/v1.0.0-rc.0...v1.0.0-rc.1) (2024-10-12) 61 | 62 | 63 | 64 | 65 | ### Bug Fixes: 66 | 67 | * missing files in mix.exs to ship the js 68 | 69 | ## [v1.0.0-rc.0](https://github.com/mrdotb/live_react/compare/v0.2.0-rc.0...v1.0.0-rc.0) (2024-10-05) 70 | ### Breaking Changes: 71 | 72 | * vitejs: switch from Mix Esbuild to Vite.js 73 | 74 | ## [v0.2.0-rc.0](https://github.com/mrdotb/live_react/compare/v0.2.0-rc.0...v0.2.0-rc.0) (2024-09-17) 75 | 76 | ### Features 77 | 78 | * Add SSR support 79 | 80 | ### Bug Fixes: 81 | 82 | * ssr: remove compiler warning when using live_react without SSR 83 | 84 | ## v0.1.0 (2024-06-29) 85 | 86 | Initial release 87 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2024 Mrdotb 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Github CI](https://github.com/mrdotb/live_react/workflows/Tests/badge.svg)](https://github.com/mrdotb/live_react/actions) 2 | [![Hex.pm](https://img.shields.io/hexpm/v/live_react.svg)](https://hex.pm/packages/live_react) 3 | [![Hexdocs.pm](https://img.shields.io/badge/docs-hexdocs.pm-purple)](https://hexdocs.pm/live_react) 4 | [![GitHub](https://img.shields.io/github/stars/mrdotb/live_react?style=social)](https://github.com/mrdotb/live_react) 5 | 6 | # LiveReact 7 | 8 | React inside Phoenix LiveView. 9 | 10 | ![logo](https://github.com/mrdotb/live_react/blob/main/logo.svg?raw=true) 11 | 12 | ## Features 13 | 14 | - ⚡ **End-To-End Reactivity** with LiveView 15 | - 🔋 **Server-Side Rendered** (SSR) React 16 | - 🦄 **Tailwind** Support 17 | - 💀 **Dead View** Support 18 | - 🐌 **Lazy-loading** React Components 19 | - 🦥 **Slot** Interoperability 20 | - 🚀 **Amazing DX** with Vite 21 | 22 | ## Resources 23 | 24 | - [Demo](https://live-react-examples.fly.dev/simple) 25 | - [HexDocs](https://hexdocs.pm/live_react) 26 | - [HexPackage](https://hex.pm/packages/live_react) 27 | - [Phoenix LiveView](https://github.com/phoenixframework/phoenix_live_view) 28 | - [Installation](/guides/installation.md) 29 | - [Deployment](/guides/deployment.md) 30 | - [Development](/guides/development.md) 31 | - [SSR](/guides/ssr.md) 32 | 33 | ## Example 34 | 35 | Visit the [demo website](https://live-react-examples.fly.dev/simple) to see examples of what you can do with LiveReact. 36 | 37 | You can also check out the [PhoenixAnalytics project](https://github.com/lalabuy948/PhoenixAnalytics) for a real-world example. 38 | 39 | ## Why LiveReact 40 | 41 | Phoenix LiveView enables rich, real-time user experiences with server-rendered HTML. 42 | It works by communicating any state changes through a websocket and updating the DOM in realtime. 43 | You can get a really good user experience without ever needing to write any client side code. 44 | 45 | LiveReact builds on top of Phoenix LiveView to allow for easy client side state management while still allowing for communication over the websocket. 46 | 47 | ## Installation 48 | 49 | see [Installation](/guides/installation.md) 50 | 51 | ## Roadmap 🎯 52 | 53 | - [ ] `useLiveForm` - an utility to efforlessly use Ecto changesets & server-side validation, similar to HEEX 54 | - [ ] Add support for Phoenix streams as props 55 | 56 | ## Credits 57 | 58 | I was inspired by the following libraries: 59 | 60 | - [LiveVue](https://github.com/Valian/live_vue) 61 | - [LiveSvelte](https://github.com/woutdp/live_svelte) 62 | 63 | I had a need for a similar library for React and so I created LiveReact 👍 64 | -------------------------------------------------------------------------------- /assets/copy/js/server.js: -------------------------------------------------------------------------------- 1 | // Used by the node.js worker for server-side rendering 2 | import { getRender } from "live_react/server"; 3 | import components from "../react-components"; 4 | 5 | export const render = getRender(components); 6 | -------------------------------------------------------------------------------- /assets/copy/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "type": "module", 4 | "scripts": { 5 | "dev": "vite --host -l warn", 6 | "build": "tsc && vite build", 7 | "build-server": "tsc && vite build --ssr js/server.js --out-dir ../priv/react-components --minify esbuild && echo '{\"type\": \"module\" } ' > ../priv/react-components/package.json" 8 | }, 9 | "devDependencies": { 10 | "@tailwindcss/forms": "^0.5.7", 11 | "@tailwindcss/postcss": "^4.1.4", 12 | "@tailwindcss/vite": "^4.1.4", 13 | "@types/react": "^18.3.11", 14 | "@types/react-dom": "^18.3.0", 15 | "@vitejs/plugin-react": "^4.3.1", 16 | "typescript": "^5.6.2", 17 | "vite": "^6.3.3" 18 | }, 19 | "dependencies": { 20 | "live_react": "file:../deps/live_react", 21 | "phoenix": "file:../deps/phoenix", 22 | "phoenix_html": "file:../deps/phoenix_html", 23 | "phoenix_live_view": "file:../deps/phoenix_live_view", 24 | "topbar": "^2.0.2", 25 | "react": "^19.1.0", 26 | "react-dom": "^19.1.0", 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /assets/copy/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | "@tailwindcss/postcss": {}, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /assets/copy/react-components/index.js: -------------------------------------------------------------------------------- 1 | import { Simple } from "./simple"; 2 | 3 | export default { 4 | Simple, 5 | }; 6 | -------------------------------------------------------------------------------- /assets/copy/react-components/simple.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export function Simple({}) { 4 | return
Hello world!
; 5 | } 6 | -------------------------------------------------------------------------------- /assets/copy/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "lib": ["dom", "dom.iterable", "ES2020"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "types": ["vite/client"], 8 | "esModuleInterop": true, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "esnext", 13 | "moduleResolution": "bundler", 14 | "isolatedModules": true, 15 | "resolveJsonModule": true, 16 | "noEmit": true, 17 | "jsx": "react", 18 | "sourceMap": true, 19 | "declaration": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "incremental": true, 23 | "noFallthroughCasesInSwitch": true, 24 | 25 | "paths": { 26 | "@/*": ["./*"] 27 | } 28 | }, 29 | "include": ["js/*", "react/**/*"] 30 | } 31 | -------------------------------------------------------------------------------- /assets/copy/vite.config.js: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { defineConfig } from "vite"; 3 | import tailwindcss from "@tailwindcss/vite"; 4 | 5 | import react from "@vitejs/plugin-react"; 6 | import liveReactPlugin from "live_react/vite-plugin"; 7 | 8 | // https://vitejs.dev/config/ 9 | export default defineConfig(({ command }) => { 10 | const isDev = command !== "build"; 11 | 12 | return { 13 | base: isDev ? undefined : "/assets", 14 | publicDir: "static", 15 | plugins: [ 16 | react(), 17 | liveReactPlugin(), 18 | tailwindcss(), 19 | ], 20 | ssr: { 21 | // we need it, because in SSR build we want no external 22 | // and in dev, we want external for fast updates 23 | noExternal: isDev ? undefined : true, 24 | }, 25 | resolve: { 26 | alias: { 27 | "@": path.resolve(__dirname, "."), 28 | }, 29 | }, 30 | optimizeDeps: { 31 | // these packages are loaded as file:../deps/ imports 32 | // so they're not optimized for development by vite by default 33 | // we want to enable it for better DX 34 | // more https://vitejs.dev/guide/dep-pre-bundling#monorepos-and-linked-dependencies 35 | include: ["live_react", "phoenix", "phoenix_html", "phoenix_live_view"], 36 | }, 37 | build: { 38 | commonjsOptions: { transformMixedEsModules: true }, 39 | target: "es2020", 40 | outDir: "../priv/static/assets", // emit assets to priv/static/assets 41 | emptyOutDir: true, 42 | sourcemap: isDev, // enable source map in dev build 43 | manifest: false, // do not generate manifest.json 44 | rollupOptions: { 45 | input: { 46 | app: path.resolve(__dirname, "./js/app.js"), 47 | }, 48 | output: { 49 | // remove hashes to match phoenix way of handling asssets 50 | entryFileNames: "[name].js", 51 | chunkFileNames: "[name].js", 52 | assetFileNames: "[name][extname]", 53 | }, 54 | }, 55 | }, 56 | }; 57 | }); 58 | -------------------------------------------------------------------------------- /assets/js/live_react/context.jsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext } from "react"; 2 | 3 | export const LiveReactContext = createContext(null); 4 | 5 | export function LiveReactProvider({ children, ...props }) { 6 | return ( 7 | 8 | {children} 9 | 10 | ); 11 | } 12 | 13 | export function useLiveReact() { 14 | return useContext(LiveReactContext); 15 | } 16 | -------------------------------------------------------------------------------- /assets/js/live_react/hooks.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import { getComponentTree } from "./utils"; 4 | 5 | function getAttributeJson(el, attributeName) { 6 | const data = el.getAttribute(attributeName); 7 | return data ? JSON.parse(data) : {}; 8 | } 9 | 10 | function getChildren(hook) { 11 | const dataSlots = getAttributeJson(hook.el, "data-slots"); 12 | 13 | if (!dataSlots?.default) { 14 | return []; 15 | } 16 | 17 | return [ 18 | React.createElement("div", { 19 | dangerouslySetInnerHTML: { __html: atob(dataSlots.default).trim() }, 20 | }), 21 | ]; 22 | } 23 | 24 | function getProps(hook) { 25 | return { 26 | ...getAttributeJson(hook.el, "data-props"), 27 | pushEvent: hook.pushEvent.bind(hook), 28 | pushEventTo: hook.pushEventTo.bind(hook), 29 | handleEvent: hook.handleEvent.bind(hook), 30 | removeHandleEvent: hook.removeHandleEvent.bind(hook), 31 | upload: hook.upload.bind(hook), 32 | uploadTo: hook.uploadTo.bind(hook), 33 | }; 34 | } 35 | 36 | export function getHooks(components) { 37 | const ReactHook = { 38 | _render() { 39 | const tree = getComponentTree( 40 | this._Component, 41 | getProps(this), 42 | getChildren(this), 43 | ); 44 | this._root.render(tree); 45 | }, 46 | mounted() { 47 | const componentName = this.el.getAttribute("data-name"); 48 | if (!componentName) { 49 | throw new Error("Component name must be provided"); 50 | } 51 | 52 | this._Component = components[componentName]; 53 | 54 | const isSSR = this.el.hasAttribute("data-ssr"); 55 | 56 | if (isSSR) { 57 | const tree = getComponentTree( 58 | this._Component, 59 | getProps(this), 60 | getChildren(this), 61 | ); 62 | this._root = ReactDOM.hydrateRoot(this.el, tree); 63 | } else { 64 | this._root = ReactDOM.createRoot(this.el); 65 | this._render(); 66 | } 67 | }, 68 | updated() { 69 | if (this._root) { 70 | this._render(); 71 | } 72 | }, 73 | destroyed() { 74 | if (this._root) { 75 | window.addEventListener( 76 | "phx:page-loading-stop", 77 | () => this._root.unmount(), 78 | { once: true }, 79 | ); 80 | } 81 | }, 82 | }; 83 | 84 | return { ReactHook }; 85 | } 86 | -------------------------------------------------------------------------------- /assets/js/live_react/index.d.mts: -------------------------------------------------------------------------------- 1 | export interface LiveProps { 2 | pushEvent: ( 3 | event: string, 4 | payload?: Record, 5 | onReply?: (reply: Record) => void, 6 | ) => Promise | void; 7 | pushEventTo: ( 8 | phxTarget: string | HTMLElement, 9 | event: string, 10 | payload?: Record, 11 | onReply?: (reply: Record) => void, 12 | ) => Promise | void; 13 | handleEvent: ( 14 | event: string, 15 | callback: (payload: Record) => void, 16 | ) => string; 17 | removeHandleEvent: (callbackRef: string) => void; 18 | upload: (name: string, files: FileList | File[]) => void; 19 | uploadTo: (target: string, name: string, files: FileList | File[]) => void; 20 | } 21 | 22 | export function useLiveReact(): LiveProps; 23 | -------------------------------------------------------------------------------- /assets/js/live_react/index.mjs: -------------------------------------------------------------------------------- 1 | export { getHooks } from "./hooks"; 2 | export { useLiveReact } from "./context"; 3 | -------------------------------------------------------------------------------- /assets/js/live_react/server.mjs: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { renderToString } from "react-dom/server"; 3 | import { getComponentTree } from "./utils"; 4 | 5 | function getChildren(slots) { 6 | if (!slots?.default) { 7 | return []; 8 | } 9 | 10 | return [ 11 | React.createElement("div", { 12 | dangerouslySetInnerHTML: { __html: slots.default.trim() }, 13 | }), 14 | ]; 15 | } 16 | 17 | export function getRender(components) { 18 | return function render(name, props, slots) { 19 | const Component = components[name]; 20 | if (!Component) { 21 | throw new Error(`Component "${name}" not found`); 22 | } 23 | const children = getChildren(slots); 24 | const tree = getComponentTree(Component, props, children); 25 | 26 | // https://react.dev/reference/react-dom/server/renderToString 27 | return renderToString(tree); 28 | }; 29 | } 30 | -------------------------------------------------------------------------------- /assets/js/live_react/utils.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { LiveReactProvider } from "./context"; 3 | 4 | function getHooks(props) { 5 | return { 6 | pushEvent: props.pushEvent, 7 | pushEventTo: props.pushEventTo, 8 | handleEvent: props.handleEvent, 9 | removeHandleEvent: props.removeHandleEvent, 10 | upload: props.upload, 11 | uploadTo: props.uploadTo, 12 | }; 13 | } 14 | 15 | export function getComponentTree(Component, props, children) { 16 | const componentInstance = React.createElement(Component, props, ...children); 17 | 18 | return React.createElement( 19 | LiveReactProvider, 20 | getHooks(props), 21 | componentInstance, 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /assets/js/live_react/vite-plugin.js: -------------------------------------------------------------------------------- 1 | function hotUpdateType(path) { 2 | if (path.endsWith("css")) return "css-update"; 3 | if (path.endsWith("js")) return "js-update"; 4 | return null; 5 | } 6 | 7 | const jsonResponse = (res, statusCode, data) => { 8 | res.statusCode = statusCode; 9 | res.setHeader("Content-Type", "application/json"); 10 | res.end(JSON.stringify(data)); 11 | }; 12 | 13 | // Custom JSON parsing middleware 14 | const jsonMiddleware = (req, res, next) => { 15 | let data = ""; 16 | 17 | // Listen for data event to collect the chunks of data 18 | req.on("data", (chunk) => { 19 | data += chunk; 20 | }); 21 | 22 | // Listen for end event to finish data collection 23 | req.on("end", () => { 24 | try { 25 | // Parse the collected data as JSON 26 | req.body = JSON.parse(data); 27 | next(); // Proceed to the next middleware 28 | } catch (error) { 29 | // Handle JSON parse error 30 | jsonResponse(res, 400, { error: "Invalid JSON" }); 31 | } 32 | }); 33 | 34 | // Handle error event 35 | req.on("error", (err) => { 36 | console.error(err); 37 | jsonResponse(res, 500, { error: "Internal Server Error" }); 38 | }); 39 | }; 40 | 41 | function liveReactPlugin(opts = {}) { 42 | return { 43 | name: "live-react", 44 | handleHotUpdate({ file, modules, server, timestamp }) { 45 | if (file.match(/\.(heex|ex)$/)) { 46 | // if it's and .ex or .heex file, invalidate all related files so they'll be updated correctly 47 | const invalidatedModules = new Set(); 48 | for (const mod of modules) { 49 | server.moduleGraph.invalidateModule( 50 | mod, 51 | invalidatedModules, 52 | timestamp, 53 | true, 54 | ); 55 | } 56 | 57 | const updates = Array.from(invalidatedModules) 58 | .filter((m) => hotUpdateType(m.file)) 59 | .map((m) => ({ 60 | type: hotUpdateType(m.file), 61 | path: m.url, 62 | acceptedPath: m.url, 63 | timestamp: timestamp, 64 | })); 65 | 66 | // ask client to hot-reload updated modules 67 | server.ws.send({ type: "update", updates: updates }); 68 | 69 | // we handle the hot update ourselves 70 | return []; 71 | } 72 | }, 73 | configureServer(server) { 74 | // Terminate the watcher when Phoenix quits 75 | // configureServer is only called in dev, so it's safe to use here 76 | process.stdin.on("close", () => process.exit(0)); 77 | process.stdin.resume(); 78 | 79 | // setup SSR endpoint /ssr_render 80 | const path = opts.path || "/ssr_render"; 81 | const entrypoint = opts.entrypoint || "./js/server.js"; 82 | server.middlewares.use(function liveReactMiddleware(req, res, next) { 83 | if (req.method == "POST" && req.url.split("?", 1)[0] === path) { 84 | jsonMiddleware(req, res, async () => { 85 | try { 86 | const render = (await server.ssrLoadModule(entrypoint)).render; 87 | const html = await render( 88 | req.body.name, 89 | req.body.props, 90 | req.body.slots, 91 | ); 92 | res.end(html); 93 | } catch (e) { 94 | server.ssrFixStacktrace(e); 95 | jsonResponse(res, 500, { error: e }); 96 | } 97 | }); 98 | } else { 99 | next(); 100 | } 101 | }); 102 | }, 103 | }; 104 | } 105 | 106 | module.exports = liveReactPlugin; 107 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | if Mix.env() == :dev do 4 | config :git_ops, 5 | mix_project: Mix.Project.get!(), 6 | changelog_file: "CHANGELOG.md", 7 | repository_url: "https://github.com/mrdotb/live_react", 8 | # Instructs the tool to manage your mix version in your `mix.exs` file 9 | # See below for more information 10 | manage_mix_version?: true, 11 | # Instructs the tool to manage the version in your README.md 12 | # Pass in `true` to use `"README.md"` or a string to customize 13 | manage_readme_version: [ 14 | "README.md" 15 | ], 16 | version_tag_prefix: "v" 17 | end 18 | -------------------------------------------------------------------------------- /guides/deployment.md: -------------------------------------------------------------------------------- 1 | # Deployment 2 | 3 | [Without SSR](#without-ssr) 4 | [With SSR](#with-ssr) 5 | 6 | ## Without SSR 7 | 8 | The following steps are needed to deploy to Fly.io. This guide assumes that you'll be using Fly Postgres as your database. Further guidance on how to deploy to Fly.io can be found [here](https://fly.io/docs/elixir/getting-started/). 9 | 10 | 1. Generate a `Dockerfile`: 11 | 12 | ```bash 13 | mix phx.gen.release --docker 14 | ``` 15 | 16 | 2. Modify the generated `Dockerfile` to install `curl`, which is used to install `nodejs` (version 19 or greater), and also add a step to install our `npm` dependencies: 17 | 18 | ```diff 19 | # ./Dockerfile 20 | 21 | ... 22 | 23 | # install build dependencies 24 | - RUN apt-get update -y && apt-get install -y build-essential git \ 25 | + RUN apt-get update -y && apt-get install -y build-essential git curl \ 26 | && apt-get clean && rm -f /var/lib/apt/lists/*_* 27 | 28 | + # install nodejs for build stage 29 | + RUN curl -fsSL https://deb.nodesource.com/setup_19.x | bash - && apt-get install -y nodejs 30 | 31 | ... 32 | 33 | COPY assets assets 34 | 35 | + # install all npm packages in assets directory 36 | + RUN cd /app/assets && npm install 37 | ``` 38 | 39 | Note: `nodejs` is installed in the build stage. This is because we need `nodejs` to install our `npm` dependencies. 40 | 41 | 3. Launch your app with the Fly.io CLI: 42 | 43 | ```bash 44 | fly launch 45 | ``` 46 | 47 | 4. When prompted to tweak settings, choose `y`: 48 | 49 | ```bash 50 | ? Do you want to tweak these settings before proceeding? (y/N) y 51 | ``` 52 | 53 | This will launch a new window where you can tweak your launch settings. In the database section, choose `Fly Postgres` and enter a name for your database. You may also want to change your database to the development configuration to avoid extra costs. You can leave the rest of the settings as-is unless you want to change them. 54 | 55 | Deployment will continue once you hit confirm. 56 | 57 | 5. Once the deployment completes, run the following command to see your deployed app! 58 | 59 | ```bash 60 | fly apps open 61 | ``` 62 | 63 | ## With SSR 64 | 65 | See the [SSR guide](/guides/ssr.md) first to setup your project. 66 | 67 | The following steps are needed to deploy to Fly.io. This guide assumes that you'll be using Fly Postgres as your database. Further guidance on how to deploy to Fly.io can be found [here](https://fly.io/docs/elixir/getting-started/). 68 | 69 | 1. Generate a `Dockerfile`: 70 | 71 | ```bash 72 | mix phx.gen.release --docker 73 | ``` 74 | 75 | 2. Modify the generated `Dockerfile` to install `curl`, which is used to install `nodejs` (version 19 or greater), and also add a step to install our `npm` dependencies: 76 | 77 | ```diff 78 | # ./Dockerfile 79 | 80 | ... 81 | 82 | # install build dependencies 83 | - RUN apt-get update -y && apt-get install -y build-essential git \ 84 | + RUN apt-get update -y && apt-get install -y build-essential git curl \ 85 | && apt-get clean && rm -f /var/lib/apt/lists/*_* 86 | 87 | + # install nodejs for build stage 88 | + RUN curl -fsSL https://deb.nodesource.com/setup_19.x | bash - && apt-get install -y nodejs 89 | 90 | ... 91 | 92 | COPY assets assets 93 | 94 | + # install all npm packages in assets directory 95 | + RUN cd /app/assets && npm install 96 | 97 | ... 98 | 99 | # start a new build stage so that the final image will only contain 100 | # the compiled release and other runtime necessities 101 | FROM ${RUNNER_IMAGE} 102 | 103 | RUN apt-get update -y && \ 104 | - apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates \ 105 | + apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates curl \ 106 | && apt-get clean && rm -f /var/lib/apt/lists/*_* 107 | 108 | + # install nodejs for production environment 109 | + RUN curl -fsSL https://deb.nodesource.com/setup_19.x | bash - && apt-get install -y nodejs 110 | 111 | ... 112 | ``` 113 | 114 | Note: `nodejs` is installed BOTH in the build stage and in the final image. This is because we need `nodejs` to install our `npm` dependencies and also need it when running our app. 115 | 116 | 3. Launch your app with the Fly.io CLI: 117 | 118 | ```bash 119 | fly launch 120 | ``` 121 | 122 | 4. When prompted to tweak settings, choose `y`: 123 | 124 | ```bash 125 | ? Do you want to tweak these settings before proceeding? (y/N) y 126 | ``` 127 | 128 | This will launch a new window where you can tweak your launch settings. In the database section, choose `Fly Postgres` and enter a name for your database. You may also want to change your database to the development configuration to avoid extra costs. You can leave the rest of the settings as-is unless you want to change them. 129 | 130 | Deployment will continue once you hit confirm. 131 | 132 | 5. Once the deployment completes, run the following command to see your deployed app! 133 | 134 | ```bash 135 | fly apps open 136 | ``` 137 | -------------------------------------------------------------------------------- /guides/development.md: -------------------------------------------------------------------------------- 1 | # Development 2 | 3 | The easiest way to get started with development is to clone live_react and run the examples 4 | 5 | ```bash 6 | git clone https://github.com/mrdotb/live_react.git 7 | cd live_react_examples 8 | ``` 9 | -------------------------------------------------------------------------------- /guides/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | LiveReact replaces `hex esbuild` with [Vite](https://vite.dev/) for both client side code and SSR to achieve a better development experience. Why ? 4 | 5 | - Vite provides a best-in-class Hot-Reload functionality and offers [many benefits](https://vitejs.dev/guide/why#why-vite) not present in esbuild 6 | - `hex esbuild` package doesn't support plugins, while it's possible to do ssr with `hex esbuild` (check [v0.2.0-rc-0](https://github.com/mrdotb/live_react/tree/v0.2.0-rc.0)) the SSR in development is broken. 7 | - the integration to react and ssr is more documented with Vite 8 | 9 | In production, we'll use [elixir-nodejs](https://github.com/revelrylabs/elixir-nodejs) for SSR. If you don't need SSR, you can disable it with one line of code. TypeScript will be supported as well. 10 | 11 | ## Steps 12 | 13 | 0. install nodejs (I recommend [mise](https://mise.jdx.dev/)) 14 | 15 | 1. Add `live_react` to your list of dependencies in `mix.exs` and run `mix deps.get` 16 | 17 | ```elixir 18 | def deps do 19 | [ 20 | {:live_react, "~> 1.0.1"}, 21 | {:nodejs, "~> 3.1.2"} # if you want to use SSR in production 22 | ] 23 | end 24 | ``` 25 | 26 | 2. Add a config entry to your `config/dev.exs` 27 | 28 | ```elixir 29 | config :live_react, 30 | vite_host: "http://localhost:5173", 31 | ssr_module: LiveReact.SSR.ViteJS, 32 | ssr: true 33 | ``` 34 | 35 | 3. Add a config entry to your `config/prod.exs` 36 | 37 | ```elixir 38 | config :live_react, 39 | ssr_module: LiveReact.SSR.NodeJS, 40 | ssr: true # or false if you don't want SSR in production 41 | ``` 42 | 43 | 4. Add `import LiveReact` in `html_helpers/0` inside `/lib/_web.ex` like so: 44 | 45 | ```elixir 46 | # /lib/_web.ex 47 | 48 | defp html_helpers do 49 | quote do 50 | 51 | # ... 52 | 53 | import LiveReact # <-- Add this line 54 | 55 | # ... 56 | 57 | end 58 | end 59 | ``` 60 | 61 | 5. LiveReact comes with a handy mix task to setup all the required files. It won't alter any files you already have in your project, you need to adjust them on your own by looking at the [sources](https://github.com/mrdotb/live_react/tree/main/assets/copy). Additional instructions how to adjust `package.json` can be found at the end of this page. 62 | 63 | It will create: 64 | 65 | - `package.json` 66 | - vite, typescript and postcss configs 67 | - server entrypoint 68 | - react components root 69 | 70 | 6. Run the following in your terminal 71 | 72 | ```bash 73 | mix deps.get 74 | mix live_react.setup 75 | npm install --prefix assets 76 | ``` 77 | 78 | 7. Add the following to your `assets/js/app.js` file 79 | 80 | ```javascript 81 | ... 82 | import topbar from "topbar" // instead of ../vendor/topbar 83 | import { getHooks } from "live_react"; 84 | import components from "../react-components"; 85 | import "../css/app.css" // the css file is handled by vite 86 | 87 | const hooks = { 88 | // ... your other hooks 89 | ...getHooks(components), 90 | }; 91 | 92 | ... 93 | 94 | let liveSocket = new LiveSocket("/live", Socket, { 95 | hooks: hooks, // <- pass the hooks 96 | longPollFallbackMs: 2500, 97 | params: { _csrf_token: csrfToken }, 98 | }); 99 | ... 100 | ``` 101 | 102 | 7. For tailwind support, make some addition to `content` in the `assets/tailwind.config.js` file 103 | 104 | ```javascript 105 | content: [ 106 | ... 107 | "./react-components/**/*.jsx", // <- if you are using jsx 108 | "./react-components/**/*.tsx" // <- if you are using tsx 109 | ], 110 | 111 | ``` 112 | 113 | 8. Let's update `root.html.heex` to use Vite files in development. There's a handy wrapper for it. 114 | 115 | ```html 116 | 117 | 118 | 120 | 121 | ``` 122 | 123 | 9. Update `mix.exs` aliases and remove `tailwind` and `esbuild` packages 124 | 125 | ```elixir 126 | defp aliases do 127 | [ 128 | setup: ["deps.get", "assets.setup", "assets.build"], 129 | "assets.setup": ["cmd --cd assets npm install"], 130 | "assets.build": [ 131 | "cmd --cd assets npm run build", 132 | "cmd --cd assets npm run build-server" 133 | ], 134 | "assets.deploy": [ 135 | "cmd --cd assets npm run build", 136 | "cmd --cd assets npm run build-server", 137 | "phx.digest" 138 | ] 139 | ] 140 | end 141 | 142 | defp deps do 143 | [ 144 | # remove these lines, we don't need esbuild or tailwind here anymore 145 | # {:esbuild, "~> 0.8", runtime: Mix.env() == :dev}, 146 | # {:tailwind, "~> 0.2", runtime: Mix.env() == :dev}, 147 | ] 148 | end 149 | ``` 150 | 151 | 10. Remove esbuild and tailwind config from `config/config.exs` 152 | 153 | 11. Update watchers in `config/dev.exs` to look like this 154 | 155 | ```elixir 156 | config :my_app, MyAppWeb.Endpoint, 157 | # ... 158 | watchers: [ 159 | npm: ["run", "dev", cd: Path.expand("../assets", __DIR__)] 160 | ] 161 | ``` 162 | 163 | 12. To make SSR working with `LiveReact.SSR.NodeJS` in production, you have to add this entry to your `application.ex` supervision tree to run the NodeJS server 164 | 165 | If you don't want SSR in production, you can skip this step. 166 | 167 | ```elixir 168 | children = [ 169 | ... 170 | {NodeJS.Supervisor, [path: LiveReact.SSR.NodeJS.server_path(), pool_size: 4]}, 171 | # note Adjust the pool_size depending of the machine 172 | ] 173 | ``` 174 | 175 | 13. Confirm everything is working by rendering the default React component anywhere in your Dead or Live Views 176 | 177 | ```elixir 178 | <.react name="Simple" /> 179 | ``` 180 | 181 | 14. (Optional) enable [stateful hot reload](https://twitter.com/jskalc/status/1788308446007132509) of phoenix LiveViews - it allows for stateful reload across the whole stack 🤯. Just adjust your `dev.exs` to look like this - add `notify` section and remove `live|components` from patterns. 182 | 183 | ```elixir 184 | # Watch static and templates for browser reloading. 185 | config :my_app, MyAppWeb.Endpoint, 186 | live_reload: [ 187 | notify: [ 188 | live_view: [ 189 | ~r"lib/my_app_web/core_components.ex$", 190 | ~r"lib/my_app_web/(live|components)/.*(ex|heex)$" 191 | ] 192 | ], 193 | patterns: [ 194 | ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$", 195 | ~r"lib/my_app_web/controllers/.*(ex|heex)$" 196 | ] 197 | ] 198 | ``` 199 | 200 | Profit! 💸 201 | 202 | ## Adjusting your own package.json 203 | 204 | Install these packages 205 | 206 | ```bash 207 | cd assets 208 | 209 | # vite 210 | npm install -D vite @vitejs/plugin-react 211 | 212 | # tailwind 213 | npm install -D @tailwindcss/forms @tailwindcss/postcss @tailwindcss/vite 214 | 215 | # typescript 216 | npm install -D typescript @types/react @types/react-dom 217 | 218 | # runtime dependencies 219 | npm install --save react react-dom topbar ../deps/live_react ../deps/phoenix ../deps/phoenix_html ../deps/phoenix_live_view 220 | 221 | # remove topbar from vendor, since we'll use it from node_modules 222 | rm vendor/topbar.js 223 | ``` 224 | 225 | and add these scripts used by watcher and `mix assets.build` command 226 | 227 | ```json 228 | { 229 | "private": true, 230 | "type": "module", 231 | "scripts": { 232 | "dev": "vite --host -l warn", 233 | "build": "tsc && vite build", 234 | "build-server": "tsc && vite build --ssr js/server.js --out-dir ../priv/react-components --minify esbuild && echo '{\"type\": \"module\" } ' > ../priv/react-components/package.json" 235 | } 236 | } 237 | ``` 238 | -------------------------------------------------------------------------------- /guides/ssr.md: -------------------------------------------------------------------------------- 1 | # Server Side Rendering (SSR) 2 | 3 | _Disclaimer_ SSR for React is not a simple topic and there is a lot of issue than can arise depending on what React components you are using. It also consume more ressource since a nodejs worker is needed for the rendering. This is a simple implementation that works for the components and library I have tested. 4 | 5 | ## Project setup 6 | 7 | ⚠️ **Warning:** Server-side rendering (SSR) requires a Node.js worker. With a `pool_size` of 1 and the Phoenix app, you need at least **512MiB** of memory. Otherwise, the instance may experience **out-of-memory (OOM)** errors or severe slowness. 8 | 9 | SSR requires Node.js to render the javascript on server side. Add `nodejs` to your mix file. 10 | 11 | ```elixir 12 | defp deps do 13 | [ 14 | {:nodejs, "~> 3.1"}, 15 | ... 16 | ] 17 | end 18 | ``` 19 | 20 | Add NodeJs.Supervisor to your `application.ex` 21 | 22 | ```elixir 23 | def start(_type, _args) do 24 | children = [ 25 | ... 26 | {NodeJS.Supervisor, [path: LiveReact.SSR.NodeJS.server_path(), pool_size: 4]}, 27 | ] 28 | end 29 | ``` 30 | 31 | Add a config entry to your `config/prod.exs` 32 | 33 | ```elixir 34 | config :live_react, 35 | ssr_module: LiveReact.SSR.NodeJS, 36 | ssr: true 37 | ``` 38 | 39 | For complete deployment follow the [SSR deployment guide](/guides/deployment.md#with-ssr) 40 | -------------------------------------------------------------------------------- /lib/live_react.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveReact do 2 | @moduledoc """ 3 | See READ.md for installation instructions and examples. 4 | """ 5 | 6 | use Phoenix.Component 7 | import Phoenix.HTML 8 | 9 | alias Phoenix.LiveView 10 | alias LiveReact.SSR 11 | alias LiveReact.Slots 12 | 13 | require Logger 14 | 15 | @ssr_default Application.compile_env(:live_react, :ssr, true) 16 | 17 | @doc """ 18 | Render a React component. 19 | """ 20 | def react(assigns) do 21 | init = assigns.__changed__ == nil 22 | dead = assigns[:socket] == nil or not LiveView.connected?(assigns[:socket]) 23 | render_ssr? = init and dead and Map.get(assigns, :ssr, @ssr_default) 24 | 25 | # we manually compute __changed__ for the computed props and slots so it's not sent without reason 26 | {props, props_changed?} = extract(assigns, :props) 27 | {slots, slots_changed?} = extract(assigns, :slots) 28 | component_name = Map.get(assigns, :name) 29 | 30 | assigns = 31 | assigns 32 | |> Map.put_new(:class, nil) 33 | |> Map.put(:__component_name, component_name) 34 | |> Map.put(:props, props) 35 | |> Map.put(:slots, if(slots_changed?, do: Slots.rendered_slot_map(slots), else: %{})) 36 | 37 | assigns = 38 | Map.put(assigns, :ssr_render, if(render_ssr?, do: ssr_render(assigns), else: nil)) 39 | 40 | computed_changed = 41 | %{ 42 | props: props_changed?, 43 | slots: slots_changed?, 44 | ssr_render: render_ssr? 45 | } 46 | 47 | assigns = 48 | update_in(assigns.__changed__, fn 49 | nil -> nil 50 | changed -> for {k, true} <- computed_changed, into: changed, do: {k, true} 51 | end) 52 | 53 | # It's important to not add extra `\n` in the inner div or it will break hydration 54 | ~H""" 55 |
Slots.base_encode_64 |> json}"} 60 | data-ssr={is_map(@ssr_render)} 61 | phx-update="ignore" 62 | phx-hook="ReactHook" 63 | class={@class} 64 | ><%= raw(@ssr_render[:html]) %>
65 | """ 66 | end 67 | 68 | defp extract(assigns, type) do 69 | Enum.reduce(assigns, {%{}, false}, fn {key, value}, {acc, changed} -> 70 | case normalize_key(key, value) do 71 | ^type -> {Map.put(acc, key, value), changed || key_changed(assigns, key)} 72 | _ -> {acc, changed} 73 | end 74 | end) 75 | end 76 | 77 | defp normalize_key(key, _val) when key in ~w(id class ssr name socket __changed__ __given__)a, 78 | do: :special 79 | 80 | defp normalize_key(_key, [%{__slot__: _}]), do: :slots 81 | defp normalize_key(key, val) when is_atom(key), do: key |> to_string() |> normalize_key(val) 82 | defp normalize_key(_key, _val), do: :props 83 | 84 | defp key_changed(%{__changed__: nil}, _key), do: true 85 | defp key_changed(%{__changed__: changed}, key), do: changed[key] != nil 86 | 87 | defp ssr_render(assigns) do 88 | try do 89 | name = Map.get(assigns, :name) 90 | 91 | SSR.render(name, assigns.props, assigns.slots) 92 | rescue 93 | SSR.NotConfigured -> 94 | nil 95 | end 96 | end 97 | 98 | defp json(data), do: Jason.encode!(data, escape: :html_safe) 99 | 100 | defp id(name) do 101 | # a small trick to avoid collisions of IDs but keep them consistent across dead and live render 102 | # id(name) is called only once during the whole LiveView lifecycle because it's not using any assigns 103 | number = Process.get(:live_react_counter, 1) 104 | Process.put(:live_react_counter, number + 1) 105 | "#{name}-#{number}" 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /lib/live_react/reload.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveReact.Reload do 2 | @moduledoc """ 3 | Utilities for easier integration with Vite in development 4 | """ 5 | 6 | use Phoenix.Component 7 | 8 | attr(:assets, :list, required: true) 9 | slot(:inner_block, required: true, doc: "what should be rendered when Vite path is not defined") 10 | 11 | @doc """ 12 | Renders the vite assets in development, and in production falls back to normal compiled assets 13 | """ 14 | def vite_assets(assigns) do 15 | assigns = 16 | assigns 17 | |> assign( 18 | :stylesheets, 19 | for(path <- assigns.assets, String.ends_with?(path, ".css"), do: path) 20 | ) 21 | |> assign( 22 | :javascripts, 23 | for( 24 | path <- assigns.assets, 25 | String.ends_with?(path, ".js") || String.ends_with?(path, ".ts"), 26 | do: path 27 | ) 28 | ) 29 | 30 | # maybe make it configurable in other way than by presence of vite_host config? 31 | # https://vitejs.dev/guide/backend-integration.html 32 | ~H""" 33 | <%= if Application.get_env(:live_react, :vite_host) do %> 34 | 41 | 43 | 44 | 46 | <% else %> 47 | <%= render_slot(@inner_block) %> 48 | <% end %> 49 | """ 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/live_react/slots.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveReact.Slots do 2 | @moduledoc false 3 | 4 | import Phoenix.Component 5 | 6 | @doc false 7 | def rendered_slot_map(assigns) do 8 | for( 9 | {key, [%{__slot__: _}] = slot} <- assigns, 10 | into: %{}, 11 | do: 12 | case(key) do 13 | :inner_block -> 14 | {:default, render(%{slot: slot})} 15 | 16 | slot_name -> 17 | raise "Unsupported slot: #{slot_name}, only one default slot is supported, passed as React children." 18 | end 19 | ) 20 | end 21 | 22 | @doc false 23 | def base_encode_64(assigns) do 24 | for {key, value} <- assigns, into: %{}, do: {key, Base.encode64(value)} 25 | end 26 | 27 | @doc false 28 | defp render(assigns) do 29 | ~H""" 30 | <%= if assigns[:slot] do %> 31 | <%= render_slot(@slot) %> 32 | <% end %> 33 | """ 34 | |> Phoenix.HTML.Safe.to_iodata() 35 | |> List.to_string() 36 | |> String.trim() 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/live_react/ssr.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveReact.SSR.NotConfigured do 2 | @moduledoc false 3 | 4 | defexception [:message] 5 | end 6 | 7 | defmodule LiveReact.SSR do 8 | require Logger 9 | 10 | @moduledoc """ 11 | A behaviour for rendering React components server-side. 12 | 13 | To define a custom renderer, change the application config in `config.exs`: 14 | 15 | config :live_react, ssr_module: MyCustomSSRModule 16 | 17 | Exposes a telemetry span for each render under key `[:live_react, :ssr]` 18 | """ 19 | 20 | @type component_name :: String.t() 21 | @type props :: %{optional(String.t() | atom) => any} 22 | @type slots :: %{optional(String.t()) => any} 23 | 24 | @typedoc """ 25 | A render response which should have shape 26 | 27 | %{ 28 | html: string, 29 | } 30 | """ 31 | @type render_response :: %{optional(String.t() | atom) => any} 32 | 33 | @callback render(component_name, props, slots) :: render_response | no_return 34 | 35 | @spec render(component_name, props, slots) :: render_response | no_return 36 | def render(name, props, slots) do 37 | case Application.get_env(:live_react, :ssr_module, nil) do 38 | nil -> 39 | %{preloadLinks: "", html: ""} 40 | 41 | mod -> 42 | meta = %{component: name, props: props, slots: slots} 43 | 44 | body = 45 | :telemetry.span([:live_react, :ssr], meta, fn -> 46 | {mod.render(name, props, slots), meta} 47 | end) 48 | 49 | with body when is_binary(body) <- body do 50 | case String.split(body, "", parts: 2) do 51 | [links, html] -> %{preloadLinks: links, html: html} 52 | [body] -> %{preloadLinks: "", html: body} 53 | end 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/live_react/ssr/node_js.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveReact.SSR.NodeJS do 2 | @moduledoc """ 3 | Implements SSR by using `NodeJS` package. 4 | 5 | Under the hood, it invokes "render" function exposed by `server.js` file. 6 | You can see how `server.js` is created by looking at `assets.deploy` command 7 | and `package.json` build-server script. 8 | """ 9 | 10 | @behaviour LiveReact.SSR 11 | 12 | def render(name, props, slots) do 13 | filename = Application.get_env(:live_react, :ssr_filepath, "./react-components/server.js") 14 | 15 | if Code.ensure_loaded?(NodeJS) do 16 | try do 17 | # Dynamically apply the NodeJS.call!/3 to avoid compiler warning 18 | apply(NodeJS, :call!, [ 19 | {filename, "render"}, 20 | [name, props, slots], 21 | [binary: true, esm: true] 22 | ]) 23 | catch 24 | :exit, {:noproc, _} -> 25 | message = """ 26 | NodeJS is not configured. Please add the following to your application.ex: 27 | {NodeJS.Supervisor, [path: LiveReact.SSR.NodeJS.server_path(), pool_size: 4]}, 28 | """ 29 | 30 | raise %LiveReact.SSR.NotConfigured{message: message} 31 | end 32 | else 33 | message = """ 34 | NodeJS is not installed. Please add the following to mix.ex deps: 35 | `{:nodejs, "~> 3.1"}` 36 | """ 37 | 38 | raise %LiveReact.SSR.NotConfigured{message: message} 39 | end 40 | end 41 | 42 | def server_path() do 43 | {:ok, path} = :application.get_application() 44 | Application.app_dir(path, "/priv") 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/live_react/ssr/vite_js.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveReact.SSR.ViteJS do 2 | @moduledoc """ 3 | Implements SSR by making a POST request to `http://{:vite_host}/ssr_render`. 4 | 5 | `ssr_render` is implemented as a Vite plugin. You have to add it to the `vite.config.js` plugins section. 6 | 7 | ```javascript 8 | import liveReactPlugin from "live_react/vite-plugin"; 9 | 10 | { 11 | publicDir: "static", 12 | plugins: [react(), liveReactPlugin()], 13 | // ... 14 | } 15 | """ 16 | 17 | @behaviour LiveReact.SSR 18 | 19 | def render(name, props, slots) do 20 | data = Jason.encode!(%{name: name, props: props, slots: slots}) 21 | url = vite_path("/ssr_render") 22 | params = {String.to_charlist(url), [], ~c"application/json", data} 23 | 24 | case :httpc.request(:post, params, [], []) do 25 | {:ok, {{_, 200, _}, _headers, body}} -> 26 | :erlang.list_to_binary(body) 27 | 28 | {:ok, {{_, 500, _}, _headers, body}} -> 29 | case Jason.decode(body) do 30 | {:ok, %{"error" => %{"message" => msg, "loc" => loc, "frame" => frame}}} -> 31 | {:error, "#{msg}\n#{loc["file"]}:#{loc["line"]}:#{loc["column"]}\n#{frame}"} 32 | 33 | _ -> 34 | {:error, "Unexpected Vite SSR response: 500 #{body}"} 35 | end 36 | 37 | {:ok, {{_, status, code}, _headers, _body}} -> 38 | {:error, "Unexpected Vite SSR response: #{status} #{code}"} 39 | 40 | {:error, {:failed_connect, [{:to_address, {url, port}}, {_, _, code}]}} -> 41 | {:error, "Unable to connect to Vite #{url}:#{port}: #{code}"} 42 | end 43 | end 44 | 45 | @doc """ 46 | A handy utility returning path relative to Vite JS host. 47 | """ 48 | def vite_path(path) do 49 | case Application.get_env(:live_react, :vite_host) do 50 | nil -> 51 | message = """ 52 | Vite.js host is not configured. Please add the following to config/dev.ex 53 | 54 | config :live_react, vite_host: "http://localhost:5173" 55 | 56 | and ensure vite.js is running 57 | """ 58 | 59 | raise %LiveReact.SSR.NotConfigured{message: message} 60 | 61 | host -> 62 | # we get rid of assets prefix since for vite /assets is root 63 | Path.join(host, path) 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/live_react/test.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveReact.Test do 2 | @moduledoc """ 3 | Helpers for testing LiveReact components and views. 4 | 5 | ## Overview 6 | 7 | LiveReact testing differs from traditional Phoenix LiveView testing in how components 8 | are rendered and inspected: 9 | 10 | * In Phoenix LiveView testing, you use `Phoenix.LiveViewTest.render_component/2` 11 | to get the final rendered HTML 12 | * In LiveReact testing, `render_component/2` returns an unrendered LiveReact root 13 | element containing the React component's configuration 14 | 15 | This module provides helpers to extract and inspect React component data from the 16 | LiveReact root element, including: 17 | 18 | * Component name and ID 19 | * Props passed to the component 20 | * Event handlers and their operations 21 | * Server-side rendering (SSR) status 22 | * Slot content 23 | * CSS classes 24 | 25 | ## Examples 26 | 27 | # Render a LiveReact component and inspect its properties 28 | {:ok, view, _html} = live(conn, "/") 29 | react = LiveReact.Test.get_react(view) 30 | 31 | # Basic component info 32 | assert react.component == "MyComponent" 33 | assert react.props["title"] == "Hello" 34 | 35 | # Event handlers 36 | assert react.handlers["click"] == JS.push("click") 37 | 38 | # SSR status and styling 39 | assert react.ssr == true 40 | assert react.class == "my-custom-class" 41 | """ 42 | 43 | @compile {:no_warn_undefined, Floki} 44 | 45 | @doc """ 46 | Extracts React component information from a LiveView or HTML string. 47 | 48 | When multiple React components are present, you can specify which one to extract using 49 | either the `:name` or `:id` option. 50 | 51 | Returns a map containing the component's configuration: 52 | * `:component` - The React component name (from `v-component` attribute) 53 | * `:id` - The unique component identifier (auto-generated or explicitly set) 54 | * `:props` - The decoded props passed to the component 55 | * `:handlers` - Map of event handlers (`v-on:*`) and their operations 56 | * `:slots` - Base64 encoded slot content 57 | * `:ssr` - Boolean indicating if server-side rendering was performed 58 | * `:class` - CSS classes applied to the component root element 59 | 60 | ## Options 61 | * `:name` - Find component by name (from `v-component` attribute) 62 | * `:id` - Find component by ID 63 | 64 | ## Examples 65 | 66 | # From a LiveView, get first React component 67 | {:ok, view, _html} = live(conn, "/") 68 | react = LiveReact.Test.get_react(view) 69 | 70 | # Get specific component by name 71 | react = LiveReact.Test.get_react(view, name: "MyComponent") 72 | 73 | # Get specific component by ID 74 | react = LiveReact.Test.get_react(view, id: "my-component-1") 75 | """ 76 | def get_react(view, opts \\ []) 77 | 78 | def get_react(view, opts) when is_struct(view, Phoenix.LiveViewTest.View) do 79 | view |> Phoenix.LiveViewTest.render() |> get_react(opts) 80 | end 81 | 82 | def get_react(html, opts) when is_binary(html) do 83 | if Code.ensure_loaded?(Floki) do 84 | react = 85 | html 86 | |> Floki.parse_document!() 87 | |> Floki.find("[phx-hook='ReactHook']") 88 | |> find_component!(opts) 89 | 90 | %{ 91 | props: Jason.decode!(attr(react, "data-props")), 92 | component: attr(react, "data-name"), 93 | id: attr(react, "id"), 94 | slots: extract_base64_slots(attr(react, "data-slots")), 95 | ssr: if(is_nil(attr(react, "data-ssr")), do: false, else: true), 96 | class: attr(react, "class") 97 | } 98 | else 99 | raise "Floki is not installed. Add {:floki, \">= 0.30.0\", only: :test} to your dependencies to use LiveReact.Test" 100 | end 101 | end 102 | 103 | defp extract_base64_slots(slots) do 104 | slots 105 | |> Jason.decode!() 106 | |> Enum.map(fn {key, value} -> {key, Base.decode64!(value)} end) 107 | |> Enum.into(%{}) 108 | end 109 | 110 | defp find_component!(components, opts) do 111 | available = Enum.map_join(components, ", ", &"#{attr(&1, "data-name")}##{attr(&1, "id")}") 112 | 113 | components = 114 | Enum.reduce(opts, components, fn 115 | {:id, id}, result -> 116 | with [] <- Enum.filter(result, &(attr(&1, "id") == id)) do 117 | raise "No React component found with id=\"#{id}\". Available components: #{available}" 118 | end 119 | 120 | {:name, name}, result -> 121 | with [] <- Enum.filter(result, &(attr(&1, "data-name") == name)) do 122 | raise "No React component found with name=\"#{name}\". Available components: #{available}" 123 | end 124 | 125 | {key, _}, _result -> 126 | raise ArgumentError, "invalid keyword option for get_react/2: #{key}" 127 | end) 128 | 129 | case components do 130 | [react | _] -> 131 | react 132 | 133 | [] -> 134 | raise "No React components found in the rendered HTML" 135 | end 136 | end 137 | 138 | defp attr(element, name) do 139 | case Floki.attribute(element, name) do 140 | [value] -> value 141 | [] -> nil 142 | end 143 | end 144 | end 145 | -------------------------------------------------------------------------------- /lib/mix/tasks/setup.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.LiveReact.Setup do 2 | @moduledoc """ 3 | Copies files from assets/copy of the live_react dependency to phoenix project assets folder 4 | """ 5 | @shortdoc "copy setup files to assets" 6 | 7 | use Mix.Task 8 | 9 | @impl Mix.Task 10 | def run(_args) do 11 | Mix.Project.deps_paths(depth: 1) 12 | |> Map.fetch!(:live_react) 13 | |> Path.join("assets/copy/**/{*.*}") 14 | |> Path.wildcard(match_dot: true) 15 | |> Enum.each(fn full_path -> 16 | [_beginning, relative_path] = String.split(full_path, "copy", parts: 2) 17 | new_path = "assets" <> relative_path 18 | 19 | case File.exists?(new_path) do 20 | true -> 21 | log_info(~s/Did not copy `#{full_path}` to `#{new_path}` since file already exists/) 22 | 23 | false -> 24 | Mix.Generator.copy_file(full_path, new_path) 25 | end 26 | end) 27 | end 28 | 29 | defp log_info(status), do: Mix.shell().info([status, :reset]) 30 | end 31 | -------------------------------------------------------------------------------- /live_react_examples/.dockerignore: -------------------------------------------------------------------------------- 1 | # This file excludes paths from the Docker build context. 2 | # 3 | # By default, Docker's build context includes all files (and folders) in the 4 | # current directory. Even if a file isn't copied into the container it is still sent to 5 | # the Docker daemon. 6 | # 7 | # There are multiple reasons to exclude files from the build context: 8 | # 9 | # 1. Prevent nested folders from being copied into the container (ex: exclude 10 | # /assets/node_modules when copying /assets) 11 | # 2. Reduce the size of the build context and improve build time (ex. /build, /deps, /doc) 12 | # 3. Avoid sending files containing sensitive information 13 | # 14 | # More information on using .dockerignore is available here: 15 | # https://docs.docker.com/engine/reference/builder/#dockerignore-file 16 | 17 | .dockerignore 18 | 19 | # Ignore git, but keep git HEAD and refs to access current commit hash if needed: 20 | # 21 | # $ cat .git/HEAD | awk '{print ".git/"$2}' | xargs cat 22 | # d0b8727759e1e0e7aa3d41707d12376e373d5ecc 23 | .git 24 | !.git/HEAD 25 | !.git/refs 26 | 27 | # Common development/test artifacts 28 | /cover/ 29 | /doc/ 30 | /test/ 31 | /tmp/ 32 | .elixir_ls 33 | 34 | # Mix artifacts 35 | /_build/ 36 | /deps/ 37 | *.ez 38 | 39 | # Generated on crash by the VM 40 | erl_crash.dump 41 | 42 | # Static artifacts - These should be fetched and built inside the Docker image 43 | /assets/node_modules/ 44 | /priv/static/assets/ 45 | /priv/static/cache_manifest.json 46 | -------------------------------------------------------------------------------- /live_react_examples/.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:phoenix], 3 | plugins: [Phoenix.LiveView.HTMLFormatter], 4 | inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}"] 5 | ] 6 | -------------------------------------------------------------------------------- /live_react_examples/.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Temporary files, for example, from tests. 23 | /tmp/ 24 | 25 | # Ignore package tarball (built via "mix hex.build"). 26 | live_react_examples-*.tar 27 | 28 | # Ignore assets that are produced by build tools. 29 | /priv/static/assets/ 30 | /priv/react-components/ 31 | 32 | # Ignore digested assets cache. 33 | /priv/static/cache_manifest.json 34 | 35 | # In case you use Node.js/npm, you want to ignore these. 36 | npm-debug.log 37 | /assets/node_modules/ 38 | /assets/meta.json 39 | /assets/tsconfig.tsbuildinfo 40 | -------------------------------------------------------------------------------- /live_react_examples/.prettierignore: -------------------------------------------------------------------------------- 1 | priv/ 2 | live_react_examples/deps/ 3 | deps/ 4 | doc/ 5 | -------------------------------------------------------------------------------- /live_react_examples/Dockerfile: -------------------------------------------------------------------------------- 1 | # Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian 2 | # instead of Alpine to avoid DNS resolution issues in production. 3 | # 4 | # https://hub.docker.com/r/hexpm/elixir/tags?page=1&name=ubuntu 5 | # https://hub.docker.com/_/ubuntu?tab=tags 6 | # 7 | # This file is based on these images: 8 | # 9 | # - https://hub.docker.com/r/hexpm/elixir/tags - for the build image 10 | # - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20240612-slim - for the release image 11 | # - https://pkgs.org/ - resource for finding needed packages 12 | # - Ex: hexpm/elixir:1.17.1-erlang-26.2.5.1-debian-bullseye-20240612-slim 13 | # 14 | ARG ELIXIR_VERSION=1.17.3 15 | ARG OTP_VERSION=27.1.1 16 | ARG DEBIAN_VERSION=bookworm-20240926 17 | 18 | ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}" 19 | ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}" 20 | 21 | FROM ${BUILDER_IMAGE} as builder 22 | 23 | # install build dependencies 24 | RUN apt-get update -y && apt-get install -y build-essential git curl \ 25 | && apt-get clean && rm -f /var/lib/apt/lists/*_* 26 | 27 | # install nodejs for build stage 28 | RUN curl -fsSL https://deb.nodesource.com/setup_19.x | bash - && apt-get install -y nodejs 29 | 30 | # prepare build dir 31 | WORKDIR /app 32 | 33 | # install hex + rebar 34 | RUN mix local.hex --force && \ 35 | mix local.rebar --force 36 | 37 | # set build ENV 38 | ENV MIX_ENV="prod" 39 | 40 | # install mix dependencies 41 | COPY mix.exs mix.lock ./ 42 | RUN mix deps.get --only $MIX_ENV 43 | RUN mkdir config 44 | 45 | # copy compile-time config files before we compile dependencies 46 | # to ensure any relevant config change will trigger the dependencies 47 | # to be re-compiled. 48 | COPY config/config.exs config/${MIX_ENV}.exs config/ 49 | RUN mix deps.compile 50 | 51 | COPY priv priv 52 | 53 | COPY lib lib 54 | 55 | COPY assets assets 56 | 57 | # Install npm packages 58 | RUN npm i --prefix assets --no-cache --progress=false --no-audit --loglevel=error 59 | 60 | # compile assets 61 | RUN mix assets.deploy 62 | 63 | # Compile the release 64 | RUN mix compile 65 | 66 | # Changes to config/runtime.exs don't require recompiling the code 67 | COPY config/runtime.exs config/ 68 | 69 | COPY rel rel 70 | RUN mix release 71 | 72 | # start a new build stage so that the final image will only contain 73 | # the compiled release and other runtime necessities 74 | FROM ${RUNNER_IMAGE} 75 | 76 | RUN apt-get update -y && \ 77 | apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates curl \ 78 | && apt-get clean && rm -f /var/lib/apt/lists/*_* 79 | 80 | # install nodejs for production environment 81 | RUN curl -fsSL https://deb.nodesource.com/setup_19.x | bash - && apt-get install -y nodejs 82 | 83 | # Set the locale 84 | RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen 85 | 86 | ENV LANG en_US.UTF-8 87 | ENV LANGUAGE en_US:en 88 | ENV LC_ALL en_US.UTF-8 89 | 90 | WORKDIR "/app" 91 | RUN chown nobody /app 92 | 93 | # set runner ENV 94 | ENV MIX_ENV="prod" 95 | 96 | # Only copy the final release from the build stage 97 | COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/live_react_examples ./ 98 | 99 | USER nobody 100 | 101 | # If using an environment that doesn't automatically reap zombie processes, it is 102 | # advised to add an init process such as tini via `apt-get install` 103 | # above and adding an entrypoint. See https://github.com/krallin/tini for details 104 | # ENTRYPOINT ["/tini", "--"] 105 | 106 | CMD ["/app/bin/server"] 107 | -------------------------------------------------------------------------------- /live_react_examples/README.md: -------------------------------------------------------------------------------- 1 | # LiveReactExamples 2 | 3 | To start your Phoenix server: 4 | 5 | - Run `mix setup` to install and setup dependencies 6 | - Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server` 7 | 8 | Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. 9 | 10 | Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html). 11 | 12 | ## Learn more 13 | 14 | - Official website: https://www.phoenixframework.org/ 15 | - Guides: https://hexdocs.pm/phoenix/overview.html 16 | - Docs: https://hexdocs.pm/phoenix 17 | - Forum: https://elixirforum.com/c/phoenix-forum 18 | - Source: https://github.com/phoenixframework/phoenix 19 | -------------------------------------------------------------------------------- /live_react_examples/assets/css/app.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | 3 | /* using legacy config file */ 4 | /* https://tailwindcss.com/docs/upgrade-guide#using-a-javascript-config-file */ 5 | @config "../tailwind.config.js"; 6 | 7 | @layer base { 8 | :root { 9 | --background: 0 0% 100%; 10 | --foreground: 222.2 84% 4.9%; 11 | --card: 0 0% 100%; 12 | --card-foreground: 222.2 84% 4.9%; 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 222.2 84% 4.9%; 15 | --primary: 221.2 83.2% 53.3%; 16 | --primary-foreground: 210 40% 98%; 17 | --secondary: 210 40% 96.1%; 18 | --secondary-foreground: 222.2 47.4% 11.2%; 19 | --muted: 210 40% 96.1%; 20 | --muted-foreground: 215.4 16.3% 46.9%; 21 | --accent: 210 40% 96.1%; 22 | --accent-foreground: 222.2 47.4% 11.2%; 23 | --destructive: 0 84.2% 60.2%; 24 | --destructive-foreground: 210 40% 98%; 25 | --border: 214.3 31.8% 91.4%; 26 | --input: 214.3 31.8% 91.4%; 27 | --ring: 221.2 83.2% 53.3%; 28 | --radius: 0.5rem; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /live_react_examples/assets/js/app.js: -------------------------------------------------------------------------------- 1 | // If you want to use Phoenix channels, run `mix help phx.gen.channel` 2 | // to get started and then uncomment the line below. 3 | // import "./user_socket.js" 4 | 5 | // You can include dependencies in two ways. 6 | // 7 | // The simplest option is to put them in assets/vendor and 8 | // import them using relative paths: 9 | // 10 | // import "../vendor/some-package.js" 11 | // 12 | // Alternatively, you can `npm install some-package --prefix assets` and import 13 | // them using a path starting with the package name: 14 | // 15 | // import "some-package" 16 | // 17 | // Include phoenix_html to handle method=PUT/DELETE in forms and buttons. 18 | import "phoenix_html"; 19 | // Establish Phoenix Socket and LiveView configuration. 20 | import { Socket } from "phoenix"; 21 | import { LiveSocket } from "phoenix_live_view"; 22 | import topbar from "topbar"; 23 | import components from "../react-components"; 24 | import { getHooks } from "live_react"; 25 | import "../css/app.css"; 26 | 27 | const hooks = { 28 | ...getHooks(components), 29 | }; 30 | 31 | let csrfToken = document 32 | .querySelector("meta[name='csrf-token']") 33 | .getAttribute("content"); 34 | let liveSocket = new LiveSocket("/live", Socket, { 35 | hooks: hooks, 36 | longPollFallbackMs: 2500, 37 | params: { _csrf_token: csrfToken }, 38 | }); 39 | 40 | // Show progress bar on live navigation and form submits 41 | topbar.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" }); 42 | window.addEventListener("phx:page-loading-start", (_info) => topbar.show(300)); 43 | window.addEventListener("phx:page-loading-stop", (_info) => topbar.hide()); 44 | 45 | // connect if there are any LiveViews on the page 46 | liveSocket.connect(); 47 | 48 | // expose liveSocket on window for web console debug logs and latency simulation: 49 | // >> liveSocket.enableDebug() 50 | // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session 51 | // >> liveSocket.disableLatencySim() 52 | window.liveSocket = liveSocket; 53 | -------------------------------------------------------------------------------- /live_react_examples/assets/js/server.js: -------------------------------------------------------------------------------- 1 | import components from "../react-components"; 2 | import { getRender } from "live_react/server"; 3 | 4 | export const render = getRender(components); 5 | -------------------------------------------------------------------------------- /live_react_examples/assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "type": "module", 4 | "scripts": { 5 | "dev": "vite --host", 6 | "tsc": "tsc", 7 | "build": "tsc && vite build", 8 | "build-server": "tsc && vite build --ssr js/server.js --out-dir ../priv/react-components --minify esbuild && echo '{\"type\": \"module\" } ' > ../priv/react-components/package.json" 9 | }, 10 | "devDependencies": { 11 | "@tailwindcss/forms": "^0.5.7", 12 | "@tailwindcss/postcss": "^4.1.4", 13 | "@tailwindcss/vite": "^4.1.4", 14 | "@types/react": "^18.3.11", 15 | "@types/react-dom": "^18.3.0", 16 | "@vitejs/plugin-react": "^4.3.1", 17 | "typescript": "^5.6.2", 18 | "vite": "^6.3.3" 19 | }, 20 | "dependencies": { 21 | "@radix-ui/react-slider": "^1.2.1", 22 | "@radix-ui/react-label": "^2.1.0", 23 | "class-variance-authority": "^0.7.0", 24 | "clsx": "^2.1.1", 25 | "framer-motion": "^11.2.11", 26 | "highlight.js": "^11.10.0", 27 | "live_react": "file:../..", 28 | "phoenix": "file:../deps/phoenix", 29 | "phoenix_html": "file:../deps/phoenix_html", 30 | "phoenix_live_view": "file:../deps/phoenix_live_view", 31 | "prism-react-renderer": "^2.4.0", 32 | "react": "^19.1.0", 33 | "react-dom": "^19.1.0", 34 | "react-syntax-highlighter": "^15.5.0", 35 | "sonner": "^1.5.0", 36 | "tailwind-merge": "^2.3.0", 37 | "topbar": "^2.0.2" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /live_react_examples/assets/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | "@tailwindcss/postcss": {}, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /live_react_examples/assets/react-components/components/lazy-component.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const LazyComponent = () => ( 4 |
5 |

I am a lazily loaded component!

6 |

7 | The esbuild config and the script in `root.html.heex` need to updated 8 | check source code 9 |

10 |
11 | ); 12 | 13 | export default LazyComponent; 14 | -------------------------------------------------------------------------------- /live_react_examples/assets/react-components/context.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { useLiveReact } from "live_react"; 3 | 4 | export function Context({ count }: { count: number }) { 5 | const [amount, setAmount] = useState(1); 6 | const { pushEvent } = useLiveReact(); 7 | 8 | return ( 9 |
10 |
11 | 17 | {count} 18 | 24 |
25 | 35 |
36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /live_react_examples/assets/react-components/counter.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | 3 | export function Counter({ count }) { 4 | const [amount, setAmount] = useState(1); 5 | 6 | return ( 7 |
8 |
9 | 16 | {count} 17 | 24 |
25 | 35 |
36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /live_react_examples/assets/react-components/delay-slider.tsx: -------------------------------------------------------------------------------- 1 | import { DualRangeSlider } from "./ui/dual-range-slider"; 2 | import { Label } from "./ui/label"; 3 | import * as React from "react"; 4 | import { useState } from "react"; 5 | 6 | function formatMilliseconds(ms: number): string { 7 | const seconds = Math.floor(ms / 1000) % 60; 8 | const minutes = Math.floor(ms / (1000 * 60)) % 60; 9 | const hours = Math.floor(ms / (1000 * 60 * 60)); 10 | 11 | const parts: string[] = []; 12 | if (hours > 0) parts.push(`${hours}h`); 13 | if (minutes > 0) parts.push(`${minutes}m`); 14 | if (seconds > 0 || parts.length === 0) parts.push(`${seconds}s`); 15 | 16 | return parts.join(""); 17 | } 18 | 19 | type DelaySliderProps = { 20 | label: string; 21 | inputName: string; 22 | value: [number, number]; 23 | min: number; 24 | max: number; 25 | step: number; 26 | }; 27 | 28 | export const DelaySlider: React.FC = ({ 29 | label, 30 | inputName, 31 | value, 32 | min, 33 | max, 34 | step, 35 | }) => { 36 | const [values, setValues] = useState<[number, number]>(value); 37 | 38 | const handleValueChange = (newValues: number[]) => { 39 | if (newValues.length === 2) { 40 | setValues([newValues[0], newValues[1]]); 41 | } 42 | }; 43 | 44 | return ( 45 |
46 | 47 | {formatMilliseconds(value ?? 0)}} 50 | value={values} 51 | onValueChange={handleValueChange} 52 | min={min} 53 | max={max} 54 | step={step} 55 | /> 56 |
57 | ); 58 | }; 59 | -------------------------------------------------------------------------------- /live_react_examples/assets/react-components/flash-sonner.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { Toaster, toast } from "sonner"; 3 | 4 | export function FlashSonner({ flash, pushEvent }) { 5 | if (flash.info) { 6 | toast.info(flash.info, { 7 | id: "info", 8 | duration: Infinity, 9 | richColors: true, 10 | closeButton: true, 11 | onDismiss: (t) => { 12 | pushEvent("lv:clear-flash", { key: "info" }); 13 | }, 14 | }); 15 | } 16 | 17 | if (flash.error) { 18 | toast.error(flash.error, { 19 | id: "error", 20 | richColors: true, 21 | duration: Infinity, 22 | closeButton: true, 23 | onDismiss: (t) => { 24 | pushEvent("lv:clear-flash", { key: "error" }); 25 | }, 26 | }); 27 | } 28 | 29 | return ; 30 | } 31 | -------------------------------------------------------------------------------- /live_react_examples/assets/react-components/github-code.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import hljs from "highlight.js/lib/core"; 3 | import elixir from "highlight.js/lib/languages/elixir"; 4 | import erb from "highlight.js/lib/languages/erb"; 5 | import javascript from "highlight.js/lib/languages/javascript"; 6 | import typescript from "highlight.js/lib/languages/typescript"; 7 | 8 | import "highlight.js/styles/github.css"; 9 | 10 | hljs.registerLanguage("jsx", javascript); 11 | hljs.registerLanguage("tsx", javascript); 12 | hljs.registerLanguage("elixir", elixir); 13 | hljs.registerLanguage("heex", erb); 14 | 15 | export function GithubCode({ url, language }) { 16 | const [code, setCode] = useState(""); 17 | 18 | useEffect(() => { 19 | const fetchCode = async () => { 20 | try { 21 | const response = await fetch(url); 22 | if (!response.ok) { 23 | throw new Error(`HTTP error! Status: ${response.status}`); 24 | } 25 | let text = await response.text(); 26 | text = text.trimEnd(); 27 | const highlightedCode = hljs.highlight(text, { language }).value; 28 | setCode(highlightedCode); 29 | } catch (error) { 30 | console.error("Error fetching code:", error); 31 | } 32 | }; 33 | 34 | fetchCode(); 35 | }, []); 36 | 37 | return ( 38 |
39 |       
40 |     
41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /live_react_examples/assets/react-components/index.jsx: -------------------------------------------------------------------------------- 1 | // polyfill recommended by Vite https://vitejs.dev/config/build-options#build-modulepreload 2 | import "vite/modulepreload-polyfill"; 3 | 4 | import { Context } from "./context"; 5 | import { Counter } from "./counter"; 6 | import { DelaySlider } from "./delay-slider"; 7 | import { FlashSonner } from "./flash-sonner"; 8 | import { GithubCode } from "./github-code"; 9 | import { Lazy } from "./lazy"; 10 | import { LogList } from "./log-list"; 11 | import { SSR } from "./ssr"; 12 | import { Simple } from "./simple"; 13 | import { SimpleProps } from "./simple-props"; 14 | import { Slot } from "./slot"; 15 | import { Typescript } from "./typescript"; 16 | 17 | export default { 18 | Context, 19 | Counter, 20 | DelaySlider, 21 | FlashSonner, 22 | GithubCode, 23 | Lazy, 24 | LogList, 25 | SSR, 26 | Simple, 27 | SimpleProps, 28 | Slot, 29 | Typescript, 30 | }; 31 | -------------------------------------------------------------------------------- /live_react_examples/assets/react-components/lazy.jsx: -------------------------------------------------------------------------------- 1 | import React, { Suspense } from "react"; 2 | const LazyComponent = React.lazy( 3 | () => import("./components/lazy-component.jsx"), 4 | ); 5 | 6 | export function Lazy() { 7 | return ( 8 |
9 |

Hello, Vite with Code Splitting and Lazy Loading!

10 | Loading...
}> 11 | 12 | 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /live_react_examples/assets/react-components/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /live_react_examples/assets/react-components/log-list.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import { motion, AnimatePresence } from "framer-motion"; 3 | 4 | export function LogList({ pushEvent, handleEvent }) { 5 | const [items, setItems] = useState([]); 6 | const [showItems, setShowItems] = useState(true); 7 | const [body, setBody] = useState(""); 8 | 9 | const addItem = (e) => { 10 | e.preventDefault(); 11 | pushEvent("add_item", { body }); 12 | setBody(""); 13 | }; 14 | 15 | const resetItems = () => setItems([]); 16 | 17 | useEffect(() => { 18 | handleEvent("new_item", (item) => { 19 | setItems((prevItems) => [item, ...prevItems]); 20 | }); 21 | }, []); 22 | 23 | return ( 24 |
25 | 35 | 36 |
37 |
38 | setBody(e.target.value)} 42 | className="border rounded px-2 py-1" 43 | /> 44 | 51 |
52 | 59 |
60 | 61 |
62 | {showItems && ( 63 | 64 | {items.map((item) => ( 65 | 73 |
74 | {item.id}: {item.body} 75 |
76 |
77 | ))} 78 |
79 | )} 80 |
81 |
82 | ); 83 | } 84 | -------------------------------------------------------------------------------- /live_react_examples/assets/react-components/simple-props.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export function SimpleProps({ user }) { 4 | return ( 5 |
6 | An example of how to pass a struct to React: 7 | {JSON.stringify(user)} 8 |
9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /live_react_examples/assets/react-components/simple.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export function Simple({}) { 4 | return
Hello world!
; 5 | } 6 | -------------------------------------------------------------------------------- /live_react_examples/assets/react-components/slot.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export function Slot({ children }: { children: React.ReactNode }) { 4 | return
{children}
; 5 | } 6 | -------------------------------------------------------------------------------- /live_react_examples/assets/react-components/ssr.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export function SSR({ text }) { 4 | return
{text}
; 5 | } 6 | -------------------------------------------------------------------------------- /live_react_examples/assets/react-components/typescript.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import type { LiveProps } from "live_react"; 3 | 4 | function MyButton({ title }: { title: string }) { 5 | return ( 6 | 9 | ); 10 | } 11 | 12 | export function Typescript(props: LiveProps) { 13 | console.log(props); 14 | 15 | return ( 16 |
17 |

Typescript

18 |
19 | 20 |
21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /live_react_examples/assets/react-components/ui/dual-range-slider.tsx: -------------------------------------------------------------------------------- 1 | import * as SliderPrimitive from "@radix-ui/react-slider"; 2 | import * as React from "react"; 3 | 4 | import { cn } from "../lib/utils"; 5 | 6 | interface DualRangeSliderProps 7 | extends React.ComponentProps { 8 | labelPosition?: "top" | "bottom"; 9 | label?: (value: number | undefined) => React.ReactNode; 10 | } 11 | 12 | const DualRangeSlider = React.forwardRef< 13 | React.ElementRef, 14 | DualRangeSliderProps 15 | >(({ className, label, labelPosition = "top", ...props }, ref) => { 16 | const initialValue = Array.isArray(props.value) 17 | ? props.value 18 | : [props.min, props.max]; 19 | 20 | return ( 21 | 29 | 30 | 31 | 32 | {initialValue.map((value, index) => ( 33 | 34 | 35 | {label && ( 36 | 43 | {label(value)} 44 | 45 | )} 46 | 47 | 48 | ))} 49 | 50 | ); 51 | }); 52 | DualRangeSlider.displayName = "DualRangeSlider"; 53 | 54 | export { DualRangeSlider }; 55 | -------------------------------------------------------------------------------- /live_react_examples/assets/react-components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | import * as LabelPrimitive from "@radix-ui/react-label"; 2 | import { cva, type VariantProps } from "class-variance-authority"; 3 | import * as React from "react"; 4 | 5 | import { cn } from "../lib/utils"; 6 | 7 | const labelVariants = cva( 8 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", 9 | ); 10 | 11 | const Label = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef & 14 | VariantProps 15 | >(({ className, ...props }, ref) => ( 16 | 21 | )); 22 | Label.displayName = LabelPrimitive.Root.displayName; 23 | 24 | export { Label }; 25 | -------------------------------------------------------------------------------- /live_react_examples/assets/tailwind.config.js: -------------------------------------------------------------------------------- 1 | // See the Tailwind configuration guide for advanced usage 2 | // https://tailwindcss.com/docs/configuration 3 | 4 | const plugin = require("tailwindcss/plugin"); 5 | const fs = require("fs"); 6 | const path = require("path"); 7 | 8 | module.exports = { 9 | content: [ 10 | "./js/**/*.js", 11 | "./react-components/**/*.jsx", 12 | "./react-components/**/*.tsx", 13 | "../lib/live_react_examples_web.ex", 14 | "../lib/live_react_examples_web/**/*.*ex", 15 | ], 16 | theme: { 17 | extend: { 18 | animation: { 19 | "border-beam": "border-beam calc(var(--duration)*1s) infinite linear", 20 | }, 21 | keyframes: { 22 | "border-beam": { 23 | "100%": { 24 | "offset-distance": "100%", 25 | }, 26 | }, 27 | }, 28 | colors: { 29 | brand: "#FD4F00", 30 | background: "hsl(var(--background))", 31 | border: "hsl(var(--border))", 32 | card: { 33 | DEFAULT: "hsl(var(--card))", 34 | foreground: "hsl(var(--card-foreground))", 35 | }, 36 | destructive: { 37 | DEFAULT: "hsl(var(--destructive))", 38 | foreground: "hsl(var(--destructive-foreground))", 39 | }, 40 | foreground: "hsl(var(--foreground))", 41 | input: "hsl(var(--input))", 42 | muted: { 43 | DEFAULT: "hsl(var(--muted))", 44 | foreground: "hsl(var(--muted-foreground))", 45 | }, 46 | popover: { 47 | DEFAULT: "hsl(var(--popover))", 48 | foreground: "hsl(var(--popover-foreground))", 49 | }, 50 | primary: { 51 | DEFAULT: "hsl(var(--primary))", 52 | foreground: "hsl(var(--primary-foreground))", 53 | }, 54 | ring: "hsl(var(--ring))", 55 | secondary: { 56 | DEFAULT: "hsl(var(--secondary))", 57 | foreground: "hsl(var(--secondary-foreground))", 58 | }, 59 | }, 60 | }, 61 | }, 62 | plugins: [ 63 | require("@tailwindcss/forms"), 64 | // Allows prefixing tailwind classes with LiveView classes to add rules 65 | // only when LiveView classes are applied, for example: 66 | // 67 | //
68 | // 69 | plugin(({ addVariant }) => 70 | addVariant("phx-click-loading", [ 71 | ".phx-click-loading&", 72 | ".phx-click-loading &", 73 | ]), 74 | ), 75 | plugin(({ addVariant }) => 76 | addVariant("phx-submit-loading", [ 77 | ".phx-submit-loading&", 78 | ".phx-submit-loading &", 79 | ]), 80 | ), 81 | plugin(({ addVariant }) => 82 | addVariant("phx-change-loading", [ 83 | ".phx-change-loading&", 84 | ".phx-change-loading &", 85 | ]), 86 | ), 87 | 88 | // Embeds Heroicons (https://heroicons.com) into your app.css bundle 89 | // See your `CoreComponents.icon/1` for more information. 90 | // 91 | plugin(function ({ matchComponents, theme }) { 92 | let iconsDir = path.join(__dirname, "../deps/heroicons/optimized"); 93 | let values = {}; 94 | let icons = [ 95 | ["", "/24/outline"], 96 | ["-solid", "/24/solid"], 97 | ["-mini", "/20/solid"], 98 | ["-micro", "/16/solid"], 99 | ]; 100 | icons.forEach(([suffix, dir]) => { 101 | fs.readdirSync(path.join(iconsDir, dir)).forEach((file) => { 102 | let name = path.basename(file, ".svg") + suffix; 103 | values[name] = { name, fullPath: path.join(iconsDir, dir, file) }; 104 | }); 105 | }); 106 | matchComponents( 107 | { 108 | hero: ({ name, fullPath }) => { 109 | let content = fs 110 | .readFileSync(fullPath) 111 | .toString() 112 | .replace(/\r?\n|\r/g, ""); 113 | let size = theme("spacing.6"); 114 | if (name.endsWith("-mini")) { 115 | size = theme("spacing.5"); 116 | } else if (name.endsWith("-micro")) { 117 | size = theme("spacing.4"); 118 | } 119 | return { 120 | [`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`, 121 | "-webkit-mask": `var(--hero-${name})`, 122 | mask: `var(--hero-${name})`, 123 | "mask-repeat": "no-repeat", 124 | "background-color": "currentColor", 125 | "vertical-align": "middle", 126 | display: "inline-block", 127 | width: size, 128 | height: size, 129 | }; 130 | }, 131 | }, 132 | { values }, 133 | ); 134 | }), 135 | ], 136 | }; 137 | -------------------------------------------------------------------------------- /live_react_examples/assets/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "lib": ["dom", "dom.iterable", "ES2020"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "types": ["vite/client"], 8 | "esModuleInterop": true, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "esnext", 13 | "moduleResolution": "bundler", 14 | "isolatedModules": true, 15 | "resolveJsonModule": true, 16 | "noEmit": true, 17 | "jsx": "react", 18 | "sourceMap": true, 19 | "declaration": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "incremental": true, 23 | "noFallthroughCasesInSwitch": true, 24 | 25 | "paths": { 26 | "@/*": ["./*"] 27 | } 28 | }, 29 | "include": ["js/*", "react/**/*"] 30 | } 31 | -------------------------------------------------------------------------------- /live_react_examples/assets/vite.config.js: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { defineConfig } from "vite"; 3 | import tailwindcss from "@tailwindcss/vite"; 4 | 5 | import react from "@vitejs/plugin-react"; 6 | import liveReactPlugin from "live_react/vite-plugin"; 7 | 8 | // https://vitejs.dev/config/ 9 | export default defineConfig(({ command }) => { 10 | const isDev = command !== "build"; 11 | 12 | return { 13 | base: isDev ? undefined : "/assets", 14 | publicDir: "static", 15 | plugins: [ 16 | react(), 17 | liveReactPlugin(), 18 | tailwindcss(), 19 | ], 20 | ssr: { 21 | // we need it, because in SSR build we want no external 22 | // and in dev, we want external for fast updates 23 | noExternal: isDev ? undefined : true, 24 | }, 25 | resolve: { 26 | alias: { 27 | "@": path.resolve(__dirname, "."), 28 | }, 29 | }, 30 | optimizeDeps: { 31 | // these packages are loaded as file:../deps/ imports 32 | // so they're not optimized for development by vite by default 33 | // we want to enable it for better DX 34 | // more https://vitejs.dev/guide/dep-pre-bundling#monorepos-and-linked-dependencies 35 | include: ["live_react", "phoenix", "phoenix_html", "phoenix_live_view"], 36 | }, 37 | build: { 38 | commonjsOptions: { transformMixedEsModules: true }, 39 | target: "es2020", 40 | outDir: "../priv/static/assets", // emit assets to priv/static/assets 41 | emptyOutDir: true, 42 | sourcemap: isDev, // enable source map in dev build 43 | // manifest: false, // do not generate manifest.json 44 | rollupOptions: { 45 | input: { 46 | app: path.resolve(__dirname, "./js/app.js"), 47 | }, 48 | output: { 49 | // remove hashes to match phoenix way of handling asssets 50 | entryFileNames: "[name].js", 51 | chunkFileNames: "[name].js", 52 | assetFileNames: "[name][extname]", 53 | }, 54 | }, 55 | }, 56 | }; 57 | }); 58 | -------------------------------------------------------------------------------- /live_react_examples/config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Config module. 3 | # 4 | # This configuration file is loaded before any dependency and 5 | # is restricted to this project. 6 | 7 | # General application configuration 8 | import Config 9 | 10 | config :live_react, 11 | ssr_module: LiveReact.SSR.NodeJS 12 | 13 | config :live_react_examples, 14 | generators: [timestamp_type: :utc_datetime] 15 | 16 | # Configures the endpoint 17 | config :live_react_examples, LiveReactExamplesWeb.Endpoint, 18 | url: [host: "localhost"], 19 | adapter: Bandit.PhoenixAdapter, 20 | render_errors: [ 21 | formats: [html: LiveReactExamplesWeb.ErrorHTML, json: LiveReactExamplesWeb.ErrorJSON], 22 | layout: false 23 | ], 24 | pubsub_server: LiveReactExamples.PubSub, 25 | live_view: [signing_salt: "vR6Y0p5z"] 26 | 27 | # Configures Elixir's Logger 28 | config :logger, :console, 29 | format: "$time $metadata[$level] $message\n", 30 | metadata: [:request_id] 31 | 32 | # Use Jason for JSON parsing in Phoenix 33 | config :phoenix, :json_library, Jason 34 | 35 | # Import environment specific config. This must remain at the bottom 36 | # of this file so it overrides the configuration defined above. 37 | import_config "#{config_env()}.exs" 38 | -------------------------------------------------------------------------------- /live_react_examples/config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # For development, we disable any cache and enable 4 | # debugging and code reloading. 5 | # 6 | # The watchers configuration can be used to run external 7 | # watchers to your application. For example, we can use it 8 | # to bundle .js and .css sources. 9 | config :live_react_examples, LiveReactExamplesWeb.Endpoint, 10 | # Binding to loopback ipv4 address prevents access from other machines. 11 | # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. 12 | http: [ip: {127, 0, 0, 1}, port: 4000], 13 | check_origin: false, 14 | code_reloader: true, 15 | debug_errors: true, 16 | secret_key_base: "zvJnWbYbr/b5+CzCCcin8jP0cIVLqXs6/vt2WiC5d/nVE8npISnhItLe1AIAP7Vn", 17 | watchers: [ 18 | npm: ["run", "dev", cd: Path.expand("../assets", __DIR__)] 19 | ] 20 | 21 | # ## SSL Support 22 | # 23 | # In order to use HTTPS in development, a self-signed 24 | # certificate can be generated by running the following 25 | # Mix task: 26 | # 27 | # mix phx.gen.cert 28 | # 29 | # Run `mix help phx.gen.cert` for more information. 30 | # 31 | # The `http:` config above can be replaced with: 32 | # 33 | # https: [ 34 | # port: 4001, 35 | # cipher_suite: :strong, 36 | # keyfile: "priv/cert/selfsigned_key.pem", 37 | # certfile: "priv/cert/selfsigned.pem" 38 | # ], 39 | # 40 | # If desired, both `http:` and `https:` keys can be 41 | # configured to run both http and https servers on 42 | # different ports. 43 | 44 | # Watch static and templates for browser reloading. 45 | config :live_react_examples, LiveReactExamplesWeb.Endpoint, 46 | reloadable_appps: [:live_react, :live_react_examples_web, :live_react_examples], 47 | live_reload: [ 48 | notify: [ 49 | live_views: [ 50 | ~r"lib/live_react_examples_web/core_components.ex$", 51 | ~r"lib/live_react_examples_web/(live|components)/.*(ex|heex)$" 52 | ] 53 | ], 54 | patterns: [ 55 | ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$", 56 | ~r"lib/live_react_examples_web/(controllers|live|components)/.*(ex|heex)$" 57 | ] 58 | ] 59 | 60 | config :live_react, 61 | vite_host: "http://localhost:5173", 62 | ssr_module: LiveReact.SSR.ViteJS, 63 | ssr: true 64 | 65 | # Enable dev routes for dashboard and mailbox 66 | config :live_react_examples, dev_routes: true 67 | 68 | # Do not include metadata nor timestamps in development logs 69 | config :logger, :console, format: "[$level] $message\n" 70 | 71 | # Set a higher stacktrace during development. Avoid configuring such 72 | # in production as building large stacktraces may be expensive. 73 | config :phoenix, :stacktrace_depth, 20 74 | 75 | # Initialize plugs at runtime for faster development compilation 76 | config :phoenix, :plug_init_mode, :runtime 77 | 78 | config :phoenix_live_view, 79 | # Include HEEx debug annotations as HTML comments in rendered markup 80 | debug_heex_annotations: true, 81 | # Enable helpful, but potentially expensive runtime checks 82 | enable_expensive_runtime_checks: true 83 | -------------------------------------------------------------------------------- /live_react_examples/config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Note we also include the path to a cache manifest 4 | # containing the digested version of static files. This 5 | # manifest is generated by the `mix assets.deploy` task, 6 | # which you should run after static files are built and 7 | # before starting your production server. 8 | config :live_react_examples, LiveReactExamplesWeb.Endpoint, 9 | cache_static_manifest: "priv/static/cache_manifest.json" 10 | 11 | # Do not print debug messages in production 12 | config :logger, level: :info 13 | 14 | config :live_react, 15 | ssr_module: LiveReact.SSR.NodeJS, 16 | ssr: true 17 | 18 | # Runtime production configuration, including reading 19 | # of environment variables, is done on config/runtime.exs. 20 | -------------------------------------------------------------------------------- /live_react_examples/config/runtime.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # config/runtime.exs is executed for all environments, including 4 | # during releases. It is executed after compilation and before the 5 | # system starts, so it is typically used to load production configuration 6 | # and secrets from environment variables or elsewhere. Do not define 7 | # any compile-time configuration in here, as it won't be applied. 8 | # The block below contains prod specific runtime configuration. 9 | 10 | # ## Using releases 11 | # 12 | # If you use `mix release`, you need to explicitly enable the server 13 | # by passing the PHX_SERVER=true when you start it: 14 | # 15 | # PHX_SERVER=true bin/live_react_examples start 16 | # 17 | # Alternatively, you can use `mix phx.gen.release` to generate a `bin/server` 18 | # script that automatically sets the env var above. 19 | if System.get_env("PHX_SERVER") do 20 | config :live_react_examples, LiveReactExamplesWeb.Endpoint, server: true 21 | end 22 | 23 | if config_env() == :prod do 24 | # The secret key base is used to sign/encrypt cookies and other secrets. 25 | # A default value is used in config/dev.exs and config/test.exs but you 26 | # want to use a different value for prod and you most likely don't want 27 | # to check this value into version control, so we use an environment 28 | # variable instead. 29 | secret_key_base = 30 | System.get_env("SECRET_KEY_BASE") || 31 | raise """ 32 | environment variable SECRET_KEY_BASE is missing. 33 | You can generate one by calling: mix phx.gen.secret 34 | """ 35 | 36 | host = System.get_env("PHX_HOST") || "example.com" 37 | port = String.to_integer(System.get_env("PORT") || "4000") 38 | 39 | config :live_react_examples, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY") 40 | 41 | config :live_react_examples, LiveReactExamplesWeb.Endpoint, 42 | url: [host: host, port: 443, scheme: "https"], 43 | http: [ 44 | # Enable IPv6 and bind on all interfaces. 45 | # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. 46 | # See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0 47 | # for details about using IPv6 vs IPv4 and loopback vs public addresses. 48 | ip: {0, 0, 0, 0, 0, 0, 0, 0}, 49 | port: port 50 | ], 51 | secret_key_base: secret_key_base 52 | 53 | # ## SSL Support 54 | # 55 | # To get SSL working, you will need to add the `https` key 56 | # to your endpoint configuration: 57 | # 58 | # config :live_react_examples, LiveReactExamplesWeb.Endpoint, 59 | # https: [ 60 | # ..., 61 | # port: 443, 62 | # cipher_suite: :strong, 63 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), 64 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH") 65 | # ] 66 | # 67 | # The `cipher_suite` is set to `:strong` to support only the 68 | # latest and more secure SSL ciphers. This means old browsers 69 | # and clients may not be supported. You can set it to 70 | # `:compatible` for wider support. 71 | # 72 | # `:keyfile` and `:certfile` expect an absolute path to the key 73 | # and cert in disk or a relative path inside priv, for example 74 | # "priv/ssl/server.key". For all supported SSL configuration 75 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1 76 | # 77 | # We also recommend setting `force_ssl` in your config/prod.exs, 78 | # ensuring no data is ever sent via http, always redirecting to https: 79 | # 80 | # config :live_react_examples, LiveReactExamplesWeb.Endpoint, 81 | # force_ssl: [hsts: true] 82 | # 83 | # Check `Plug.SSL` for all available options in `force_ssl`. 84 | end 85 | -------------------------------------------------------------------------------- /live_react_examples/config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # We don't run a server during test. If one is required, 4 | # you can enable the server option below. 5 | config :live_react_examples, LiveReactExamplesWeb.Endpoint, 6 | http: [ip: {127, 0, 0, 1}, port: 4002], 7 | secret_key_base: "1TSUMeDi3xh+wePzvzKMq73p/bD2psOzg340hjtEcR8WGPxm0qINVteU03whCTcS", 8 | server: false 9 | 10 | # Print only warnings and errors during test 11 | config :logger, level: :warning 12 | 13 | # Initialize plugs at runtime for faster test compilation 14 | config :phoenix, :plug_init_mode, :runtime 15 | 16 | # Enable helpful, but potentially expensive runtime checks 17 | config :phoenix_live_view, 18 | enable_expensive_runtime_checks: true 19 | -------------------------------------------------------------------------------- /live_react_examples/fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml app configuration file generated for live-react-examples on 2024-06-29T19:14:48+02:00 2 | # 3 | # See https://fly.io/docs/reference/configuration/ for information about how to use this file. 4 | # 5 | 6 | app = 'live-react-examples' 7 | primary_region = 'cdg' 8 | kill_signal = 'SIGTERM' 9 | 10 | [build] 11 | 12 | [env] 13 | PHX_HOST = 'live-react-examples.fly.dev' 14 | PORT = '8080' 15 | 16 | [http_service] 17 | internal_port = 8080 18 | force_https = true 19 | auto_stop_machines = true 20 | auto_start_machines = true 21 | min_machines_running = 0 22 | processes = ['app'] 23 | 24 | [http_service.concurrency] 25 | type = 'connections' 26 | hard_limit = 1000 27 | soft_limit = 1000 28 | 29 | [[vm]] 30 | size = 'shared-cpu-1x' 31 | -------------------------------------------------------------------------------- /live_react_examples/lib/live_react_examples.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveReactExamples do 2 | @moduledoc """ 3 | LiveReactExamples keeps the contexts that define your domain 4 | and business logic. 5 | 6 | Contexts are also responsible for managing your data, regardless 7 | if it comes from the database, an external API or others. 8 | """ 9 | 10 | @url "https://github.com/mrdotb/live_react/blob/main/live_react_examples" 11 | @raw_url "https://raw.githubusercontent.com/mrdotb/live_react/main/live_react_examples" 12 | @dead_views "/lib/live_react_examples_web/controllers/page_html" 13 | @live_views "/lib/live_react_examples_web/live" 14 | @react "/assets/react-components" 15 | 16 | def demo(name) 17 | 18 | def demo(:simple) do 19 | %{ 20 | view_type: "DeadView", 21 | raw_view_url: "#{@raw_url}/#{@dead_views}/simple.html.heex", 22 | view_url: "#{@url}/#{@dead_views}/simple.html.heex", 23 | raw_react_url: "#{@raw_url}/#{@react}/simple.jsx", 24 | react_url: "#{@url}/#{@react}/simple.jsx" 25 | } 26 | end 27 | 28 | def demo(:simple_props) do 29 | %{ 30 | view_type: "DeadView", 31 | raw_view_url: "#{@raw_url}/#{@dead_views}/simple_props.html.heex", 32 | view_url: "#{@url}/#{@dead_views}/simple_props.html.heex", 33 | raw_react_url: "#{@raw_url}/#{@react}/simple-props.jsx", 34 | react_url: "#{@url}/#{@react}/simple-props.jsx" 35 | } 36 | end 37 | 38 | def demo(:typescript) do 39 | %{ 40 | view_type: "DeadView", 41 | raw_view_url: "#{@raw_url}#{@dead_views}/typescript.html.heex", 42 | view_url: "#{@url}#{@dead_views}/typescript.html.heex", 43 | raw_react_url: "#{@raw_url}#{@react}/typescript.tsx", 44 | react_url: "#{@url}#{@react}/typescript.tsx", 45 | react_language: "tsx" 46 | } 47 | end 48 | 49 | def demo(:lazy) do 50 | %{ 51 | view_type: "DeadView", 52 | raw_view_url: "#{@raw_url}#{@dead_views}/lazy.html.heex", 53 | view_url: "#{@url}#{@dead_views}/lazy.html.heex", 54 | raw_react_url: "#{@raw_url}#{@react}/lazy.jsx", 55 | react_url: "#{@url}#{@react}/lazy.jsx" 56 | } 57 | end 58 | 59 | def demo(:counter) do 60 | %{ 61 | raw_view_url: "#{@raw_url}#{@live_views}/counter.ex", 62 | view_url: "#{@url}#{@live_views}/counter.ex", 63 | view_language: "elixir", 64 | raw_react_url: "#{@raw_url}#{@react}/counter.jsx", 65 | react_url: "#{@url}#{@react}/counter.jsx" 66 | } 67 | end 68 | 69 | def demo(:log_list) do 70 | %{ 71 | raw_view_url: "#{@raw_url}#{@live_views}/log_list.ex", 72 | view_url: "#{@url}#{@live_views}/log_list.ex", 73 | view_language: "elixir", 74 | raw_react_url: "#{@raw_url}#{@react}/log-list.jsx", 75 | react_url: "#{@url}#{@react}/log-list.jsx" 76 | } 77 | end 78 | 79 | def demo(:flash_sonner) do 80 | %{ 81 | raw_view_url: "#{@raw_url}#{@live_views}/flash_sonner.ex", 82 | view_url: "#{@url}#{@live_views}/flash_sonner.ex", 83 | view_language: "elixir", 84 | raw_react_url: "#{@raw_url}#{@react}/flash-sonner.jsx", 85 | react_url: "#{@url}#{@react}/flash-sonner.jsx" 86 | } 87 | end 88 | 89 | def demo(:ssr) do 90 | %{ 91 | raw_view_url: "#{@raw_url}#{@live_views}/ssr.ex", 92 | view_url: "#{@url}#{@live_views}/ssr.ex", 93 | view_language: "elixir", 94 | raw_react_url: "#{@raw_url}#{@react}/ssr.jsx", 95 | react_url: "#{@url}#{@react}/ssr.jsx" 96 | } 97 | end 98 | 99 | def demo(:hybrid_form) do 100 | %{ 101 | raw_view_url: "#{@raw_url}#{@live_views}/hybrid_form.ex", 102 | view_url: "#{@url}#{@live_views}/hybrid_form.ex", 103 | view_language: "elixir", 104 | raw_react_url: "#{@raw_url}#{@react}/delay-slider.tsx", 105 | react_url: "#{@url}#{@react}/delay-slider.tsx" 106 | } 107 | end 108 | 109 | def demo(:slot) do 110 | %{ 111 | raw_view_url: "#{@raw_url}#{@live_views}/slot.ex", 112 | view_url: "#{@url}#{@live_views}/slot.ex", 113 | view_language: "elixir", 114 | raw_react_url: "#{@raw_url}#{@react}/slot.tsx", 115 | react_url: "#{@url}#{@react}/slot.tsx" 116 | } 117 | end 118 | 119 | def demo(:context) do 120 | %{ 121 | raw_view_url: "#{@raw_url}#{@live_views}/context.ex", 122 | view_url: "#{@url}#{@live_views}/context.ex", 123 | view_language: "elixir", 124 | raw_react_url: "#{@raw_url}#{@react}/context.tsx", 125 | react_url: "#{@url}#{@react}/context.tsx" 126 | } 127 | end 128 | 129 | def demo(demo) do 130 | raise ArgumentError, "Unknown demo: #{inspect(demo)}" 131 | end 132 | end 133 | -------------------------------------------------------------------------------- /live_react_examples/lib/live_react_examples/application.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveReactExamples.Application do 2 | # See https://hexdocs.pm/elixir/Application.html 3 | # for more information on OTP Applications 4 | @moduledoc false 5 | 6 | use Application 7 | 8 | @impl true 9 | def start(_type, _args) do 10 | children = [ 11 | {NodeJS.Supervisor, [path: LiveReact.SSR.NodeJS.server_path(), pool_size: 1]}, 12 | LiveReactExamplesWeb.Telemetry, 13 | {DNSCluster, 14 | query: Application.get_env(:live_react_examples, :dns_cluster_query) || :ignore}, 15 | {Phoenix.PubSub, name: LiveReactExamples.PubSub}, 16 | # Start a worker by calling: LiveReactExamples.Worker.start_link(arg) 17 | # {LiveReactExamples.Worker, arg}, 18 | # Start to serve requests, typically the last entry 19 | LiveReactExamplesWeb.Endpoint 20 | ] 21 | 22 | # Set up LiveReactExamples.Telemetry 23 | LiveReactExamples.Telemetry.setup() 24 | 25 | # See https://hexdocs.pm/elixir/Supervisor.html 26 | # for other strategies and supported options 27 | opts = [strategy: :one_for_one, name: LiveReactExamples.Supervisor] 28 | Supervisor.start_link(children, opts) 29 | end 30 | 31 | # Tell Phoenix to update the endpoint configuration 32 | # whenever the application is updated. 33 | @impl true 34 | def config_change(changed, _new, removed) do 35 | LiveReactExamplesWeb.Endpoint.config_change(changed, removed) 36 | :ok 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /live_react_examples/lib/live_react_examples/telemetry.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveReactExamples.Telemetry do 2 | require Logger 3 | 4 | def setup() do 5 | :ok = 6 | :telemetry.attach( 7 | "live-react-ssr-logger", 8 | [:live_react, :ssr, :stop], 9 | &LiveReactExamples.Telemetry.handle_event/4, 10 | nil 11 | ) 12 | end 13 | 14 | def handle_event([:live_react, :ssr, :stop], %{duration: duration}, metadata, _config) do 15 | duration_ms = System.convert_time_unit(duration, :native, :microsecond) 16 | Logger.info("SSR completed for component: #{metadata.component} in #{duration_ms}µs") 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /live_react_examples/lib/live_react_examples_web.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveReactExamplesWeb do 2 | @moduledoc """ 3 | The entrypoint for defining your web interface, such 4 | as controllers, components, channels, and so on. 5 | 6 | This can be used in your application as: 7 | 8 | use LiveReactExamplesWeb, :controller 9 | use LiveReactExamplesWeb, :html 10 | 11 | The definitions below will be executed for every controller, 12 | component, etc, so keep them short and clean, focused 13 | on imports, uses and aliases. 14 | 15 | Do NOT define functions inside the quoted expressions 16 | below. Instead, define additional modules and import 17 | those modules here. 18 | """ 19 | 20 | def static_paths, do: ~w(assets fonts images favicon.ico robots.txt) 21 | 22 | def router do 23 | quote do 24 | use Phoenix.Router, helpers: false 25 | 26 | # Import common connection and controller functions to use in pipelines 27 | import Plug.Conn 28 | import Phoenix.Controller 29 | import Phoenix.LiveView.Router 30 | end 31 | end 32 | 33 | def channel do 34 | quote do 35 | use Phoenix.Channel 36 | end 37 | end 38 | 39 | def controller do 40 | quote do 41 | use Phoenix.Controller, 42 | formats: [:html, :json], 43 | layouts: [html: LiveReactExamplesWeb.Layouts] 44 | 45 | import Plug.Conn 46 | 47 | unquote(verified_routes()) 48 | end 49 | end 50 | 51 | def live_view do 52 | quote do 53 | use Phoenix.LiveView, 54 | layout: {LiveReactExamplesWeb.Layouts, :app} 55 | 56 | on_mount LiveReactExamplesWeb.LiveDemoAssigns 57 | unquote(html_helpers()) 58 | end 59 | end 60 | 61 | def live_component do 62 | quote do 63 | use Phoenix.LiveComponent 64 | 65 | unquote(html_helpers()) 66 | end 67 | end 68 | 69 | def html do 70 | quote do 71 | use Phoenix.Component 72 | 73 | # Import convenience functions from controllers 74 | import Phoenix.Controller, 75 | only: [get_csrf_token: 0, view_module: 1, view_template: 1] 76 | 77 | # Include general helpers for rendering HTML 78 | unquote(html_helpers()) 79 | end 80 | end 81 | 82 | defp html_helpers do 83 | quote do 84 | # HTML escaping functionality 85 | import Phoenix.HTML 86 | # Core UI components and translation 87 | import LiveReactExamplesWeb.CoreComponents 88 | 89 | import LiveReact 90 | 91 | # Shortcut for generating JS commands 92 | alias Phoenix.LiveView.JS 93 | 94 | # Routes generation with the ~p sigil 95 | unquote(verified_routes()) 96 | end 97 | end 98 | 99 | def verified_routes do 100 | quote do 101 | use Phoenix.VerifiedRoutes, 102 | endpoint: LiveReactExamplesWeb.Endpoint, 103 | router: LiveReactExamplesWeb.Router, 104 | statics: LiveReactExamplesWeb.static_paths() 105 | end 106 | end 107 | 108 | @doc """ 109 | When used, dispatch to the appropriate controller/live_view/etc. 110 | """ 111 | defmacro __using__(which) when is_atom(which) do 112 | apply(__MODULE__, which, []) 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /live_react_examples/lib/live_react_examples_web/components/layouts.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveReactExamplesWeb.Layouts do 2 | @moduledoc """ 3 | This module holds different layouts used by your application. 4 | 5 | See the `layouts` directory for all templates available. 6 | The "root" layout is a skeleton rendered as part of the 7 | application router. The "app" layout is set as the default 8 | layout on both `use LiveReactExamplesWeb, :controller` and 9 | `use LiveReactExamplesWeb, :live_view`. 10 | """ 11 | use LiveReactExamplesWeb, :html 12 | 13 | embed_templates "layouts/*" 14 | end 15 | -------------------------------------------------------------------------------- /live_react_examples/lib/live_react_examples_web/components/layouts/app.html.heex: -------------------------------------------------------------------------------- 1 |
2 | <.border_beam size={100} duration={10} /> 3 |
4 |
5 | 6 | 7 | 8 |

9 | LiveReact examples 10 |

11 |
12 |
13 | <.a class="flex space-x-2 items-center" href="https://github.com/mrdotb/live_react"> 14 | View on Github 15 |
16 | 17 |
18 | 19 |
20 |
21 |
22 | 23 |
24 |
25 | 110 |
111 |
112 | <.demo {LiveReactExamples.demo(@demo)}> 113 | {@inner_content} 114 | 115 |
116 |
117 |
118 |
119 | <.react :if={@demo == :flash_sonner} name="FlashSonner" flash={@flash} socket={assigns[:socket]} /> 120 | <.flash_group flash={@flash} /> 121 | -------------------------------------------------------------------------------- /live_react_examples/lib/live_react_examples_web/components/layouts/root.html.heex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <.live_title suffix=" · Phoenix Framework"> 8 | {assigns[:page_title] || "LiveReactExamples"} 9 | 10 | 11 | 12 | 14 | 15 | 16 | 17 | {@inner_content} 18 | 19 | 20 | -------------------------------------------------------------------------------- /live_react_examples/lib/live_react_examples_web/controllers/error_html.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveReactExamplesWeb.ErrorHTML do 2 | @moduledoc """ 3 | This module is invoked by your endpoint in case of errors on HTML requests. 4 | 5 | See config/config.exs. 6 | """ 7 | use LiveReactExamplesWeb, :html 8 | 9 | # If you want to customize your error pages, 10 | # uncomment the embed_templates/1 call below 11 | # and add pages to the error directory: 12 | # 13 | # * lib/live_react_examples_web/controllers/error_html/404.html.heex 14 | # * lib/live_react_examples_web/controllers/error_html/500.html.heex 15 | # 16 | # embed_templates "error_html/*" 17 | 18 | # The default is to render a plain text page based on 19 | # the template name. For example, "404.html" becomes 20 | # "Not Found". 21 | def render(template, _assigns) do 22 | Phoenix.Controller.status_message_from_template(template) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /live_react_examples/lib/live_react_examples_web/controllers/error_json.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveReactExamplesWeb.ErrorJSON do 2 | @moduledoc """ 3 | This module is invoked by your endpoint in case of errors on JSON requests. 4 | 5 | See config/config.exs. 6 | """ 7 | 8 | # If you want to customize a particular status code, 9 | # you may add your own clauses, such as: 10 | # 11 | # def render("500.json", _assigns) do 12 | # %{errors: %{detail: "Internal Server Error"}} 13 | # end 14 | 15 | # By default, Phoenix returns the status message from 16 | # the template name. For example, "404.json" becomes 17 | # "Not Found". 18 | def render(template, _assigns) do 19 | %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}} 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /live_react_examples/lib/live_react_examples_web/controllers/page_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveReactExamplesWeb.PageController do 2 | use LiveReactExamplesWeb, :controller 3 | 4 | def home(conn, _params) do 5 | redirect(conn, to: ~p"/simple") 6 | end 7 | 8 | def simple(conn, _params) do 9 | render(conn, :simple, demo: :simple) 10 | end 11 | 12 | def simple_props(conn, _params) do 13 | render(conn, :simple_props, demo: :simple_props) 14 | end 15 | 16 | def typescript(conn, _params) do 17 | render(conn, :typescript, demo: :typescript) 18 | end 19 | 20 | def lazy(conn, _params) do 21 | render(conn, :lazy, demo: :lazy) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /live_react_examples/lib/live_react_examples_web/controllers/page_html.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveReactExamplesWeb.PageHTML do 2 | @moduledoc """ 3 | This module contains pages rendered by PageController. 4 | 5 | See the `page_html` directory for all templates available. 6 | """ 7 | use LiveReactExamplesWeb, :html 8 | 9 | embed_templates "page_html/*" 10 | end 11 | -------------------------------------------------------------------------------- /live_react_examples/lib/live_react_examples_web/controllers/page_html/home.html.heex: -------------------------------------------------------------------------------- 1 | <.flash_group flash={@flash} /> 2 | 41 |
42 |
43 | 49 |

50 | Phoenix Framework 51 | 52 | v{Application.spec(:phoenix, :vsn)} 53 | 54 |

55 |

56 | Peace of mind from prototype to production. 57 |

58 |

59 | Build rich, interactive web applications quickly, with less code and fewer moving parts. Join our growing community of developers using Phoenix to craft APIs, HTML5 apps and more, for fun or at scale. 60 |

61 | 221 |
222 |
223 | -------------------------------------------------------------------------------- /live_react_examples/lib/live_react_examples_web/controllers/page_html/lazy.html.heex: -------------------------------------------------------------------------------- 1 | <.react name="Lazy" /> 2 | -------------------------------------------------------------------------------- /live_react_examples/lib/live_react_examples_web/controllers/page_html/simple.html.heex: -------------------------------------------------------------------------------- 1 | <.react name="Simple" /> 2 | -------------------------------------------------------------------------------- /live_react_examples/lib/live_react_examples_web/controllers/page_html/simple_props.html.heex: -------------------------------------------------------------------------------- 1 | <.react name="SimpleProps" user={%{name: "mrdotb", age: 30}} /> 2 | -------------------------------------------------------------------------------- /live_react_examples/lib/live_react_examples_web/controllers/page_html/typescript.html.heex: -------------------------------------------------------------------------------- 1 | <.react name="Typescript" /> 2 | -------------------------------------------------------------------------------- /live_react_examples/lib/live_react_examples_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveReactExamplesWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :live_react_examples 3 | 4 | # The session will be stored in the cookie and signed, 5 | # this means its contents can be read but not tampered with. 6 | # Set :encryption_salt if you would also like to encrypt it. 7 | @session_options [ 8 | store: :cookie, 9 | key: "_live_react_examples_key", 10 | signing_salt: "7e1J3j+/", 11 | same_site: "Lax" 12 | ] 13 | 14 | socket "/live", Phoenix.LiveView.Socket, 15 | websocket: [connect_info: [session: @session_options]], 16 | longpoll: [connect_info: [session: @session_options]] 17 | 18 | # Serve at "/" the static files from "priv/static" directory. 19 | # 20 | # You should set gzip to true if you are running phx.digest 21 | # when deploying your static files in production. 22 | plug Plug.Static, 23 | at: "/", 24 | from: :live_react_examples, 25 | gzip: false, 26 | only: LiveReactExamplesWeb.static_paths() 27 | 28 | # Code reloading can be explicitly enabled under the 29 | # :code_reloader configuration of your endpoint. 30 | if code_reloading? do 31 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket 32 | plug Phoenix.LiveReloader 33 | plug Phoenix.CodeReloader 34 | end 35 | 36 | plug Plug.RequestId 37 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] 38 | 39 | plug Plug.Parsers, 40 | parsers: [:urlencoded, :multipart, :json], 41 | pass: ["*/*"], 42 | json_decoder: Phoenix.json_library() 43 | 44 | plug Plug.MethodOverride 45 | plug Plug.Head 46 | plug Plug.Session, @session_options 47 | plug LiveReactExamplesWeb.Router 48 | end 49 | -------------------------------------------------------------------------------- /live_react_examples/lib/live_react_examples_web/live/context.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveReactExamplesWeb.LiveContext do 2 | use LiveReactExamplesWeb, :live_view 3 | 4 | def render(assigns) do 5 | ~H""" 6 |

Hybrid: LiveView + React

7 | <.react name="Context" count={@count} socket={@socket} ssr={true} /> 8 | """ 9 | end 10 | 11 | def mount(_session, _params, socket) do 12 | {:ok, assign(socket, :count, 10)} 13 | end 14 | 15 | def handle_event("set_count", %{"value" => number}, socket) do 16 | {:noreply, assign(socket, :count, number)} 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /live_react_examples/lib/live_react_examples_web/live/counter.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveReactExamplesWeb.LiveCounter do 2 | use LiveReactExamplesWeb, :live_view 3 | 4 | def render(assigns) do 5 | ~H""" 6 |

Hybrid: LiveView + React

7 | <.react name="Counter" count={@count} socket={@socket} ssr={true} /> 8 | """ 9 | end 10 | 11 | def mount(_session, _params, socket) do 12 | {:ok, assign(socket, :count, 10)} 13 | end 14 | 15 | def handle_event("set_count", %{"value" => number}, socket) do 16 | {:noreply, assign(socket, :count, String.to_integer(number))} 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /live_react_examples/lib/live_react_examples_web/live/demo_assigns.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveReactExamplesWeb.LiveDemoAssigns do 2 | @moduledoc """ 3 | Assigns the current demo state. 4 | """ 5 | 6 | import Phoenix.Component 7 | import Phoenix.LiveView 8 | 9 | def on_mount(:default, _params, _session, socket) do 10 | socket = attach_hook(socket, :active_tab, :handle_params, &set_active_demo/3) 11 | {:cont, socket} 12 | end 13 | 14 | defp set_active_demo(_params, _url, socket) do 15 | demo = 16 | case {socket.view, socket.assigns.live_action} do 17 | {LiveReactExamplesWeb.LiveCounter, _} -> 18 | :counter 19 | 20 | {LiveReactExamplesWeb.LiveLogList, _} -> 21 | :log_list 22 | 23 | {LiveReactExamplesWeb.LiveFlashSonner, _} -> 24 | :flash_sonner 25 | 26 | {LiveReactExamplesWeb.LiveSSR, _} -> 27 | :ssr 28 | 29 | {LiveReactExamplesWeb.LiveHybridForm, _} -> 30 | :hybrid_form 31 | 32 | {LiveReactExamplesWeb.LiveSlot, _} -> 33 | :slot 34 | 35 | {LiveReactExamplesWeb.LiveContext, _} -> 36 | :context 37 | 38 | {_view, _live_action} -> 39 | nil 40 | end 41 | 42 | {:cont, assign(socket, demo: demo)} 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /live_react_examples/lib/live_react_examples_web/live/flash_sonner.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveReactExamplesWeb.LiveFlashSonner do 2 | use LiveReactExamplesWeb, :live_view 3 | 4 | def render(assigns) do 5 | ~H""" 6 |

Flash sonner

7 | <.button phx-click="info" class="cursor-pointer"> 8 | info 9 | 10 | <.button phx-click="error" class="cursor-pointer"> 11 | error 12 | 13 | """ 14 | end 15 | 16 | def mount(_session, _params, socket) do 17 | {:ok, socket} 18 | end 19 | 20 | def handle_event("info", _params, socket) do 21 | {:noreply, put_flash(socket, :info, "This is an info message")} 22 | end 23 | 24 | def handle_event("error", _params, socket) do 25 | {:noreply, put_flash(socket, :error, "This is an error message")} 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /live_react_examples/lib/live_react_examples_web/live/hybrid_form.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveReactExamplesWeb.LiveHybridForm do 2 | use LiveReactExamplesWeb, :live_view 3 | 4 | def render(assigns) do 5 | ~H""" 6 |

Hybrid form

7 | 8 | <.simple_form for={@form} phx-change="validate" phx-submit="submit"> 9 | <.input field={@form[:email]} label="Email" /> 10 | <.react 11 | label="Delay Between" 12 | name="DelaySlider" 13 | inputName="settings[delay_between]" 14 | value={@form[:delay_between].value} 15 | min={2_000} 16 | max={90_000} 17 | step={2_000} 18 | /> 19 | 20 | 21 |
22 |
23 |         <%= inspect(@form.params, pretty: true) %>
24 |       
25 |
26 | """ 27 | end 28 | 29 | def mount(_session, _params, socket) do 30 | form = 31 | to_form( 32 | %{ 33 | "email" => "hello@mrdotb.com", 34 | "delay_between" => [4_000, 30_000] 35 | }, 36 | as: :settings 37 | ) 38 | 39 | socket = assign(socket, form: form) 40 | {:ok, socket} 41 | end 42 | 43 | def handle_event("validate", %{"settings" => settings}, socket) do 44 | form = to_form(settings, as: :settings, action: :validate) 45 | socket = assign(socket, form: form) 46 | {:noreply, socket} 47 | end 48 | 49 | def handle_event("submit", _, socket) do 50 | {:noreply, socket} 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /live_react_examples/lib/live_react_examples_web/live/log_list.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveReactExamplesWeb.LiveLogList do 2 | use LiveReactExamplesWeb, :live_view 3 | 4 | def render(assigns) do 5 | ~H""" 6 | <.react name="LogList" socket={@socket} /> 7 | """ 8 | end 9 | 10 | def mount(_params, _session, socket) do 11 | if connected?(socket), do: :timer.send_interval(1000, self(), :tick) 12 | {:ok, socket} 13 | end 14 | 15 | def handle_event("add_item", %{"body" => body}, socket) do 16 | socket = push_event(socket, "new_item", create_log(body)) 17 | {:noreply, socket} 18 | end 19 | 20 | def handle_info(:tick, socket) do 21 | datetime = 22 | DateTime.utc_now() 23 | |> DateTime.to_string() 24 | 25 | socket = push_event(socket, "new_item", create_log(datetime)) 26 | {:noreply, socket} 27 | end 28 | 29 | defp create_log(body) do 30 | %{id: System.unique_integer([:positive]), body: body} 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /live_react_examples/lib/live_react_examples_web/live/slot.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveReactExamplesWeb.LiveSlot do 2 | use LiveReactExamplesWeb, :live_view 3 | 4 | def render(assigns) do 5 | ~H""" 6 |

Slot

7 | <.react name="Slot" socket={@socket} ssr={true}> 8 |
button component passed as a slot and rendered
9 | <.button class="cursor-pointer"> 10 | button 11 | 12 | 13 | """ 14 | end 15 | 16 | def mount(_session, _params, socket) do 17 | {:ok, assign(socket, :count, 10)} 18 | end 19 | 20 | def handle_event("set_count", %{"value" => number}, socket) do 21 | {:noreply, assign(socket, :count, String.to_integer(number))} 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /live_react_examples/lib/live_react_examples_web/live/ssr.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveReactExamplesWeb.LiveSSR do 2 | use LiveReactExamplesWeb, :live_view 3 | 4 | def render(assigns) do 5 | ~H""" 6 |

SSR

7 | 11 | SSR guide 12 | 13 |
14 | <.react 15 | ssr={true} 16 | name="SSR" 17 | socket={@socket} 18 | text="I am rendered on Server" 19 | class="cursor-pointer" 20 | /> 21 | <.react 22 | ssr={false} 23 | name="SSR" 24 | socket={@socket} 25 | text="I am rendered on Client" 26 | class="cursor-pointer" 27 | /> 28 |
29 | """ 30 | end 31 | 32 | def mount(_session, _params, socket) do 33 | {:ok, socket} 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /live_react_examples/lib/live_react_examples_web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveReactExamplesWeb.Router do 2 | use LiveReactExamplesWeb, :router 3 | 4 | pipeline :browser do 5 | plug :accepts, ["html"] 6 | plug :fetch_session 7 | plug :fetch_live_flash 8 | plug :put_root_layout, html: {LiveReactExamplesWeb.Layouts, :root} 9 | plug :protect_from_forgery 10 | plug :put_secure_browser_headers 11 | end 12 | 13 | # pipeline :api do 14 | # plug :accepts, ["json"] 15 | # end 16 | 17 | scope "/", LiveReactExamplesWeb do 18 | pipe_through :browser 19 | 20 | get "/", PageController, :home 21 | get "/lazy", PageController, :lazy 22 | get "/simple", PageController, :simple 23 | get "/simple-props", PageController, :simple_props 24 | get "/typescript", PageController, :typescript 25 | 26 | live "/live-counter", LiveCounter 27 | live "/context", LiveContext 28 | live "/log-list", LiveLogList 29 | live "/flash-sonner", LiveFlashSonner 30 | live "/ssr", LiveSSR 31 | live "/hybrid-form", LiveHybridForm 32 | live "/slot", LiveSlot 33 | end 34 | 35 | # Other scopes may use custom stacks. 36 | # scope "/api", LiveReactExamplesWeb do 37 | # pipe_through :api 38 | # end 39 | end 40 | -------------------------------------------------------------------------------- /live_react_examples/lib/live_react_examples_web/telemetry.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveReactExamplesWeb.Telemetry do 2 | use Supervisor 3 | import Telemetry.Metrics 4 | 5 | def start_link(arg) do 6 | Supervisor.start_link(__MODULE__, arg, name: __MODULE__) 7 | end 8 | 9 | @impl true 10 | def init(_arg) do 11 | children = [ 12 | # Telemetry poller will execute the given period measurements 13 | # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics 14 | {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} 15 | # Add reporters as children of your supervision tree. 16 | # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} 17 | ] 18 | 19 | Supervisor.init(children, strategy: :one_for_one) 20 | end 21 | 22 | def metrics do 23 | [ 24 | # Phoenix Metrics 25 | summary("phoenix.endpoint.start.system_time", 26 | unit: {:native, :millisecond} 27 | ), 28 | summary("phoenix.endpoint.stop.duration", 29 | unit: {:native, :millisecond} 30 | ), 31 | summary("phoenix.router_dispatch.start.system_time", 32 | tags: [:route], 33 | unit: {:native, :millisecond} 34 | ), 35 | summary("phoenix.router_dispatch.exception.duration", 36 | tags: [:route], 37 | unit: {:native, :millisecond} 38 | ), 39 | summary("phoenix.router_dispatch.stop.duration", 40 | tags: [:route], 41 | unit: {:native, :millisecond} 42 | ), 43 | summary("phoenix.socket_connected.duration", 44 | unit: {:native, :millisecond} 45 | ), 46 | summary("phoenix.channel_joined.duration", 47 | unit: {:native, :millisecond} 48 | ), 49 | summary("phoenix.channel_handled_in.duration", 50 | tags: [:event], 51 | unit: {:native, :millisecond} 52 | ), 53 | 54 | # VM Metrics 55 | summary("vm.memory.total", unit: {:byte, :kilobyte}), 56 | summary("vm.total_run_queue_lengths.total"), 57 | summary("vm.total_run_queue_lengths.cpu"), 58 | summary("vm.total_run_queue_lengths.io") 59 | ] 60 | end 61 | 62 | defp periodic_measurements do 63 | [ 64 | # A module, function and arguments to be invoked periodically. 65 | # This function must call :telemetry.execute/3 and a metric must be added above. 66 | # {LiveReactExamplesWeb, :count_users, []} 67 | ] 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /live_react_examples/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule LiveReactExamples.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :live_react_examples, 7 | version: "0.1.0", 8 | elixir: "~> 1.14", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | start_permanent: Mix.env() == :prod, 11 | aliases: aliases(), 12 | deps: deps() 13 | ] 14 | end 15 | 16 | # Configuration for the OTP application. 17 | # 18 | # Type `mix help compile.app` for more information. 19 | def application do 20 | [ 21 | mod: {LiveReactExamples.Application, []}, 22 | extra_applications: [:logger, :runtime_tools] 23 | ] 24 | end 25 | 26 | # Specifies which paths to compile per environment. 27 | defp elixirc_paths(:test), do: ["lib", "test/support"] 28 | defp elixirc_paths(_), do: ["lib"] 29 | 30 | # Specifies your project dependencies. 31 | # 32 | # Type `mix help deps` for examples and options. 33 | defp deps do 34 | [ 35 | {:phoenix, "~> 1.7.14"}, 36 | {:phoenix_html, "~> 4.1"}, 37 | {:phoenix_live_reload, "~> 1.2", only: :dev}, 38 | {:nodejs, "~> 3.1"}, 39 | {:phoenix_live_view, "~> 1.0.0"}, 40 | {:floki, ">= 0.30.0", only: :test}, 41 | {:heroicons, 42 | github: "tailwindlabs/heroicons", 43 | tag: "v2.1.1", 44 | sparse: "optimized", 45 | app: false, 46 | compile: false, 47 | depth: 1}, 48 | {:telemetry_metrics, "~> 1.0"}, 49 | {:telemetry_poller, "~> 1.0"}, 50 | {:jason, "~> 1.2"}, 51 | {:dns_cluster, "~> 0.1.1"}, 52 | {:bandit, "~> 1.5"}, 53 | # For development 54 | {:live_react, path: ".."} 55 | # For deployment 56 | # {:live_react, "~> 1.0.0"} 57 | ] 58 | end 59 | 60 | # Aliases are shortcuts or tasks specific to the current project. 61 | # For example, to install project dependencies and perform other setup tasks, run: 62 | # 63 | # $ mix setup 64 | # 65 | # See the documentation for `Mix` for more info on aliases. 66 | defp aliases do 67 | [ 68 | setup: ["deps.get", "assets.setup", "assets.build"], 69 | "assets.setup": ["cmd --cd assets npm install"], 70 | "assets.build": [ 71 | "cmd --cd assets npm run build", 72 | "cmd --cd assets npm run build-server" 73 | ], 74 | "assets.deploy": [ 75 | "cmd --cd assets npm run build", 76 | "cmd --cd assets npm run build-server", 77 | "phx.digest" 78 | ] 79 | ] 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /live_react_examples/mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bandit": {:hex, :bandit, "1.6.11", "2fbadd60c95310eefb4ba7f1e58810aa8956e18c664a3b2029d57edb7d28d410", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "543f3f06b4721619a1220bed743aa77bf7ecc9c093ba9fab9229ff6b99eacc65"}, 3 | "castore": {:hex, :castore, "1.0.12", "053f0e32700cbec356280c0e835df425a3be4bc1e0627b714330ad9d0f05497f", [:mix], [], "hexpm", "3dca286b2186055ba0c9449b4e95b97bf1b57b47c1f2644555879e659960c224"}, 4 | "dns_cluster": {:hex, :dns_cluster, "0.1.3", "0bc20a2c88ed6cc494f2964075c359f8c2d00e1bf25518a6a6c7fd277c9b0c66", [:mix], [], "hexpm", "46cb7c4a1b3e52c7ad4cbe33ca5079fbde4840dedeafca2baf77996c2da1bc33"}, 5 | "esbuild": {:hex, :esbuild, "0.8.1", "0cbf919f0eccb136d2eeef0df49c4acf55336de864e63594adcea3814f3edf41", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "25fc876a67c13cb0a776e7b5d7974851556baeda2085296c14ab48555ea7560f"}, 6 | "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, 7 | "floki": {:hex, :floki, "0.37.1", "d7aaee758c8a5b4a7495799a4260754fec5530d95b9c383c03b27359dea117cf", [:mix], [], "hexpm", "673d040cb594d31318d514590246b6dd587ed341d3b67e17c1c0eb8ce7ca6f04"}, 8 | "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "88ab3a0d790e6a47404cba02800a6b25d2afae50", [tag: "v2.1.1", sparse: "optimized", depth: 1]}, 9 | "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, 10 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 11 | "live_react": {:hex, :live_react, "1.0.0-rc.2", "487bde279fc1cf7f6bbd6a0ee7d20e9482c03f82ffc81ae7fdeb6b87fba4912a", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:nodejs, "~> 3.1", [hex: :nodejs, repo: "hexpm", optional: true]}, {:phoenix, ">= 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, ">= 3.3.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, ">= 0.18.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c5a9138fdb62342804d2b76b4e2a70d0ba17e9b488aac899fb0fe7e72f164740"}, 12 | "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, 13 | "nodejs": {:hex, :nodejs, "3.1.3", "8693fae9fbefa14fb99329292c226df4d4711acfa5a3fa4182dd8d3f779b30bf", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:poolboy, "~> 1.5.1", [hex: :poolboy, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.7", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "e7751aad77ac55f8e6c5c07617378afd88d2e0c349d9db2ebb5273aae46ef6a9"}, 14 | "phoenix": {:hex, :phoenix, "1.7.21", "14ca4f1071a5f65121217d6b57ac5712d1857e40a0833aff7a691b7870fc9a3b", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "336dce4f86cba56fed312a7d280bf2282c720abb6074bdb1b61ec8095bdd0bc9"}, 15 | "phoenix_html": {:hex, :phoenix_html, "4.2.1", "35279e2a39140068fc03f8874408d58eef734e488fc142153f055c5454fd1c08", [:mix], [], "hexpm", "cff108100ae2715dd959ae8f2a8cef8e20b593f8dfd031c9cba92702cf23e053"}, 16 | "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.0", "2791fac0e2776b640192308cc90c0dbcf67843ad51387ed4ecae2038263d708d", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b3a1fa036d7eb2f956774eda7a7638cf5123f8f2175aca6d6420a7f95e598e1c"}, 17 | "phoenix_live_view": {:hex, :phoenix_live_view, "1.0.10", "d3d54f751ca538b17313541cabb1ab090a0d26e08ba914b49b6648022fa476f4", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "13f833a39b1368117e0529c0fe5029930a9bf11e2fb805c2263fcc32950f07a2"}, 18 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, 19 | "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, 20 | "plug": {:hex, :plug, "1.17.0", "a0832e7af4ae0f4819e0c08dd2e7482364937aea6a8a997a679f2cbb7e026b2e", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f6692046652a69a00a5a21d0b7e11fcf401064839d59d6b8787f23af55b1e6bc"}, 21 | "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, 22 | "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"}, 23 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, 24 | "tailwind": {:hex, :tailwind, "0.2.3", "277f08145d407de49650d0a4685dc062174bdd1ae7731c5f1da86163a24dfcdb", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "8e45e7a34a676a7747d04f7913a96c770c85e6be810a1d7f91e713d3a3655b5d"}, 25 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 26 | "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, 27 | "telemetry_poller": {:hex, :telemetry_poller, "1.2.0", "ba82e333215aed9dd2096f93bd1d13ae89d249f82760fcada0850ba33bac154b", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7216e21a6c326eb9aa44328028c34e9fd348fb53667ca837be59d0aa2a0156e8"}, 28 | "thousand_island": {:hex, :thousand_island, "1.3.13", "d598c609172275f7b1648c9f6eddf900e42312b09bfc2f2020358f926ee00d39", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5a34bdf24ae2f965ddf7ba1a416f3111cfe7df50de8d66f6310e01fc2e80b02a"}, 29 | "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, 30 | "websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"}, 31 | } 32 | -------------------------------------------------------------------------------- /live_react_examples/priv/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrdotb/live_react/75c1c71b1a535bc5baceeeebf38e01e18cbd9c26/live_react_examples/priv/static/favicon.ico -------------------------------------------------------------------------------- /live_react_examples/priv/static/images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /live_react_examples/priv/static/robots.txt: -------------------------------------------------------------------------------- 1 | # See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | # 3 | # To ban all spiders from the entire site uncomment the next two lines: 4 | # User-agent: * 5 | # Disallow: / 6 | -------------------------------------------------------------------------------- /live_react_examples/rel/env.sh.eex: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # configure node for distributed erlang with IPV6 support 4 | export ERL_AFLAGS="-proto_dist inet6_tcp" 5 | export ECTO_IPV6="true" 6 | export DNS_CLUSTER_QUERY="${FLY_APP_NAME}.internal" 7 | export RELEASE_DISTRIBUTION="name" 8 | export RELEASE_NODE="${FLY_APP_NAME}-${FLY_IMAGE_REF##*-}@${FLY_PRIVATE_IP}" 9 | 10 | # Uncomment to send crash dumps to stderr 11 | # This can be useful for debugging, but may log sensitive information 12 | # export ERL_CRASH_DUMP=/dev/stderr 13 | # export ERL_CRASH_DUMP_BYTES=4096 14 | -------------------------------------------------------------------------------- /live_react_examples/rel/overlays/bin/server: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -eu 3 | 4 | cd -P -- "$(dirname -- "$0")" 5 | PHX_SERVER=true exec ./live_react_examples start 6 | -------------------------------------------------------------------------------- /live_react_examples/rel/overlays/bin/server.bat: -------------------------------------------------------------------------------- 1 | set PHX_SERVER=true 2 | call "%~dp0\live_react_examples" start 3 | -------------------------------------------------------------------------------- /live_react_examples/test/live_react_examples_web/controllers/error_html_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LiveReactExamplesWeb.ErrorHTMLTest do 2 | use LiveReactExamplesWeb.ConnCase, async: true 3 | 4 | # Bring render_to_string/4 for testing custom views 5 | import Phoenix.Template 6 | 7 | test "renders 404.html" do 8 | assert render_to_string(LiveReactExamplesWeb.ErrorHTML, "404", "html", []) == "Not Found" 9 | end 10 | 11 | test "renders 500.html" do 12 | assert render_to_string(LiveReactExamplesWeb.ErrorHTML, "500", "html", []) == 13 | "Internal Server Error" 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /live_react_examples/test/live_react_examples_web/controllers/error_json_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LiveReactExamplesWeb.ErrorJSONTest do 2 | use LiveReactExamplesWeb.ConnCase, async: true 3 | 4 | test "renders 404" do 5 | assert LiveReactExamplesWeb.ErrorJSON.render("404.json", %{}) == %{ 6 | errors: %{detail: "Not Found"} 7 | } 8 | end 9 | 10 | test "renders 500" do 11 | assert LiveReactExamplesWeb.ErrorJSON.render("500.json", %{}) == 12 | %{errors: %{detail: "Internal Server Error"}} 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /live_react_examples/test/live_react_examples_web/controllers/page_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LiveReactExamplesWeb.PageControllerTest do 2 | use LiveReactExamplesWeb.ConnCase 3 | 4 | test "GET /", %{conn: conn} do 5 | conn = get(conn, ~p"/") 6 | assert html_response(conn, 200) =~ "Peace of mind from prototype to production" 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /live_react_examples/test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveReactExamplesWeb.ConnCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | tests that require setting up a connection. 5 | 6 | Such tests rely on `Phoenix.ConnTest` and also 7 | import other functionality to make it easier 8 | to build common data structures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | we enable the SQL sandbox, so changes done to the database 12 | are reverted at the end of every test. If you are using 13 | PostgreSQL, you can even run database tests asynchronously 14 | by setting `use LiveReactExamplesWeb.ConnCase, async: true`, although 15 | this option is not recommended for other databases. 16 | """ 17 | 18 | use ExUnit.CaseTemplate 19 | 20 | using do 21 | quote do 22 | # The default endpoint for testing 23 | @endpoint LiveReactExamplesWeb.Endpoint 24 | 25 | use LiveReactExamplesWeb, :verified_routes 26 | 27 | # Import conveniences for testing with connections 28 | import Plug.Conn 29 | import Phoenix.ConnTest 30 | import LiveReactExamplesWeb.ConnCase 31 | end 32 | end 33 | 34 | setup _tags do 35 | {:ok, conn: Phoenix.ConnTest.build_conn()} 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /live_react_examples/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule LiveReact.MixProject do 2 | use Mix.Project 3 | 4 | @source_url "https://github.com/mrdotb/live_react" 5 | @version "1.0.1" 6 | 7 | def project do 8 | [ 9 | app: :live_react, 10 | version: @version, 11 | elixir: "~> 1.16", 12 | start_permanent: Mix.env() == :prod, 13 | deps: deps(), 14 | description: "E2E reactivity for React and LiveView", 15 | package: package(), 16 | docs: docs(), 17 | source_url: @source_url 18 | ] 19 | end 20 | 21 | # Run "mix help compile.app" to learn about applications. 22 | def application do 23 | conditionals = 24 | case Application.get_env(:live_react, :ssr_module) do 25 | # Needed to use :httpc.request 26 | LiveReact.SSR.ViteJS -> [:inets] 27 | _ -> [] 28 | end 29 | 30 | [ 31 | extra_applications: [:logger] ++ conditionals 32 | ] 33 | end 34 | 35 | # Run "mix help deps" to learn about dependencies. 36 | defp deps do 37 | [ 38 | {:jason, "~> 1.2"}, 39 | {:nodejs, "~> 3.1", optional: true}, 40 | {:floki, ">= 0.30.0", optional: true}, 41 | {:phoenix, ">= 1.7.0"}, 42 | {:phoenix_html, ">= 3.3.1"}, 43 | {:phoenix_live_view, ">= 0.18.0"}, 44 | {:telemetry, "~> 0.4 or ~> 1.0"}, 45 | {:credo, "~> 1.7", only: [:dev, :test]}, 46 | {:ex_doc, "~> 0.19", only: :dev, runtime: false}, 47 | {:git_ops, "~> 2.7.2", only: [:dev]} 48 | ] 49 | end 50 | 51 | defp package do 52 | [ 53 | maintainers: ["Baptiste Chaleil"], 54 | licenses: ["MIT"], 55 | links: %{ 56 | Github: "https://github.com/mrdotb/live_react" 57 | }, 58 | files: 59 | ~w(assets/copy assets/js lib)s ++ 60 | ~w(CHANGELOG.md LICENSE.md mix.exs package.json README.md .formatter.exs)s 61 | ] 62 | end 63 | 64 | defp docs do 65 | [ 66 | name: "LiveReact", 67 | source_ref: "v#{@version}", 68 | source_url: "https://github.com/mrdotb/live_react", 69 | homepage_url: "https://github.com/mrdotb/live_react", 70 | main: "readme", 71 | extras: [ 72 | "README.md", 73 | "guides/installation.md", 74 | "guides/deployment.md", 75 | "guides/development.md", 76 | "guides/ssr.md", 77 | "CHANGELOG.md" 78 | ] 79 | ] 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 3 | "castore": {:hex, :castore, "1.0.12", "053f0e32700cbec356280c0e835df425a3be4bc1e0627b714330ad9d0f05497f", [:mix], [], "hexpm", "3dca286b2186055ba0c9449b4e95b97bf1b57b47c1f2644555879e659960c224"}, 4 | "credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"}, 5 | "earmark_parser": {:hex, :earmark_parser, "1.4.43", "34b2f401fe473080e39ff2b90feb8ddfeef7639f8ee0bbf71bb41911831d77c5", [:mix], [], "hexpm", "970a3cd19503f5e8e527a190662be2cee5d98eed1ff72ed9b3d1a3d466692de8"}, 6 | "ex_doc": {:hex, :ex_doc, "0.37.3", "f7816881a443cd77872b7d6118e8a55f547f49903aef8747dbcb345a75b462f9", [:mix], [{:earmark_parser, "~> 1.4.42", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "e6aebca7156e7c29b5da4daa17f6361205b2ae5f26e5c7d8ca0d3f7e18972233"}, 7 | "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, 8 | "floki": {:hex, :floki, "0.37.1", "d7aaee758c8a5b4a7495799a4260754fec5530d95b9c383c03b27359dea117cf", [:mix], [], "hexpm", "673d040cb594d31318d514590246b6dd587ed341d3b67e17c1c0eb8ce7ca6f04"}, 9 | "git_cli": {:hex, :git_cli, "0.3.0", "a5422f9b95c99483385b976f5d43f7e8233283a47cda13533d7c16131cb14df5", [:mix], [], "hexpm", "78cb952f4c86a41f4d3511f1d3ecb28edb268e3a7df278de2faa1bd4672eaf9b"}, 10 | "git_ops": {:hex, :git_ops, "2.7.2", "2d3c164a8bcaf13f129ab339e8e9f0a99c80ffa8f85dd0b344d7515275236dbc", [:mix], [{:git_cli, "~> 0.2", [hex: :git_cli, repo: "hexpm", optional: false]}, {:igniter, ">= 0.5.27 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "1dcd68b3f5bcd0999d69274cd21e74e652a90452e683b54d490fa5b26152945f"}, 11 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 12 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 13 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 14 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 15 | "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, 16 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 17 | "nodejs": {:hex, :nodejs, "3.1.3", "8693fae9fbefa14fb99329292c226df4d4711acfa5a3fa4182dd8d3f779b30bf", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:poolboy, "~> 1.5.1", [hex: :poolboy, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.7", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "e7751aad77ac55f8e6c5c07617378afd88d2e0c349d9db2ebb5273aae46ef6a9"}, 18 | "phoenix": {:hex, :phoenix, "1.7.21", "14ca4f1071a5f65121217d6b57ac5712d1857e40a0833aff7a691b7870fc9a3b", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "336dce4f86cba56fed312a7d280bf2282c720abb6074bdb1b61ec8095bdd0bc9"}, 19 | "phoenix_html": {:hex, :phoenix_html, "4.2.1", "35279e2a39140068fc03f8874408d58eef734e488fc142153f055c5454fd1c08", [:mix], [], "hexpm", "cff108100ae2715dd959ae8f2a8cef8e20b593f8dfd031c9cba92702cf23e053"}, 20 | "phoenix_live_view": {:hex, :phoenix_live_view, "1.0.10", "d3d54f751ca538b17313541cabb1ab090a0d26e08ba914b49b6648022fa476f4", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "13f833a39b1368117e0529c0fe5029930a9bf11e2fb805c2263fcc32950f07a2"}, 21 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, 22 | "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, 23 | "plug": {:hex, :plug, "1.17.0", "a0832e7af4ae0f4819e0c08dd2e7482364937aea6a8a997a679f2cbb7e026b2e", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f6692046652a69a00a5a21d0b7e11fcf401064839d59d6b8787f23af55b1e6bc"}, 24 | "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, 25 | "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"}, 26 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, 27 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 28 | "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, 29 | "websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"}, 30 | } 31 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "live_react", 3 | "version": "0.1.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "live_react", 9 | "version": "0.1.0", 10 | "license": "MIT", 11 | "devDependencies": { 12 | "prettier": "^3.3.2" 13 | } 14 | }, 15 | "node_modules/prettier": { 16 | "version": "3.4.2", 17 | "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz", 18 | "integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==", 19 | "dev": true, 20 | "bin": { 21 | "prettier": "bin/prettier.cjs" 22 | }, 23 | "engines": { 24 | "node": ">=14" 25 | }, 26 | "funding": { 27 | "url": "https://github.com/prettier/prettier?sponsor=1" 28 | } 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "live_react", 3 | "version": "0.1.0", 4 | "description": "E2E reactivity from React and LiveView", 5 | "license": "MIT", 6 | "module": "./assets/js/live_react/index.js", 7 | "exports": { 8 | ".": { 9 | "import": "./assets/js/live_react/index.mjs", 10 | "types": "./assets/js/live_react/index.d.mts" 11 | }, 12 | "./server": "./assets/js/live_react/server.mjs", 13 | "./vite-plugin": "./assets/js/live_react/vite-plugin.js" 14 | }, 15 | "author": "Baptiste Chaleil ", 16 | "repository": { 17 | "type": "git", 18 | "url": "git://github.com/mrdotb/live_react.git" 19 | }, 20 | "files": [ 21 | "README.MD", 22 | "LICENSE.md", 23 | "package.json", 24 | "assets/js/live_react/*" 25 | ], 26 | "devDependencies": { 27 | "prettier": "^3.3.2" 28 | }, 29 | "scripts": { 30 | "format": "npx prettier --write ." 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /test/live_react_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LiveReactTest do 2 | use ExUnit.Case 3 | 4 | import LiveReact 5 | import Phoenix.Component 6 | import Phoenix.LiveViewTest 7 | 8 | alias LiveReact.Test 9 | 10 | doctest LiveReact 11 | 12 | describe "basic component rendering" do 13 | def simple_component(assigns) do 14 | ~H""" 15 | <.react name="MyComponent" firstName="john" lastName="doe" /> 16 | """ 17 | end 18 | 19 | test "renders component with correct props" do 20 | html = render_component(&simple_component/1) 21 | react = Test.get_react(html) 22 | 23 | assert react.component == "MyComponent" 24 | assert react.props == %{"firstName" => "john", "lastName" => "doe"} 25 | end 26 | 27 | test "generates consistent ID" do 28 | html = render_component(&simple_component/1) 29 | react = Test.get_react(html) 30 | 31 | assert react.id =~ ~r/MyComponent-\d+/ 32 | end 33 | end 34 | 35 | describe "multiple components" do 36 | def multi_component(assigns) do 37 | ~H""" 38 |
39 | <.react id="profile-1" firstName="John" name="UserProfile" /> 40 | <.react id="card-1" firstName="Jane" name="UserCard" /> 41 |
42 | """ 43 | end 44 | 45 | test "finds first component by default" do 46 | html = render_component(&multi_component/1) 47 | react = Test.get_react(html) 48 | 49 | assert react.component == "UserProfile" 50 | assert react.props == %{"firstName" => "John"} 51 | end 52 | 53 | test "finds specific component by name" do 54 | html = render_component(&multi_component/1) 55 | react = Test.get_react(html, name: "UserCard") 56 | 57 | assert react.component == "UserCard" 58 | assert react.props == %{"firstName" => "Jane"} 59 | end 60 | 61 | test "finds specific component by id" do 62 | html = render_component(&multi_component/1) 63 | react = Test.get_react(html, id: "card-1") 64 | 65 | assert react.component == "UserCard" 66 | assert react.id == "card-1" 67 | end 68 | 69 | test "raises error when component with name not found" do 70 | html = render_component(&multi_component/1) 71 | 72 | assert_raise RuntimeError, 73 | ~r/No React component found with name="Unknown".*Available components: UserProfile#profile-1, UserCard#card-1/, 74 | fn -> 75 | Test.get_react(html, name: "Unknown") 76 | end 77 | end 78 | 79 | test "raises error when component with id not found" do 80 | html = render_component(&multi_component/1) 81 | 82 | assert_raise RuntimeError, 83 | ~r/No React component found with id="unknown-id".*Available components: UserProfile#profile-1, UserCard#card-1/, 84 | fn -> 85 | Test.get_react(html, id: "unknown-id") 86 | end 87 | end 88 | end 89 | 90 | describe "styling" do 91 | def styled_component(assigns) do 92 | ~H""" 93 | <.react name="MyComponent" class="bg-blue-500 rounded-sm" /> 94 | """ 95 | end 96 | 97 | test "applies CSS classes" do 98 | html = render_component(&styled_component/1) 99 | react = Test.get_react(html) 100 | 101 | assert react.class == "bg-blue-500 rounded-sm" 102 | end 103 | end 104 | 105 | describe "SSR behavior" do 106 | def ssr_component(assigns) do 107 | ~H""" 108 | <.react name="MyComponent" ssr={false} /> 109 | """ 110 | end 111 | 112 | test "respects SSR flag" do 113 | html = render_component(&ssr_component/1) 114 | react = Test.get_react(html) 115 | 116 | assert react.ssr == false 117 | end 118 | end 119 | 120 | describe "slots" do 121 | def component_with_named_slot(assigns) do 122 | ~H""" 123 | <.react name="WithSlots"> 124 | <:hello>Simple content 125 | 126 | """ 127 | end 128 | 129 | def component_with_inner_block(assigns) do 130 | ~H""" 131 | <.react name="WithSlots"> 132 | Simple content 133 | 134 | """ 135 | end 136 | 137 | test "warns about usage of named slot" do 138 | assert_raise RuntimeError, 139 | "Unsupported slot: hello, only one default slot is supported, passed as React children.", 140 | fn -> render_component(&component_with_named_slot/1) end 141 | end 142 | 143 | test "renders default slot with inner_block" do 144 | html = render_component(&component_with_inner_block/1) 145 | react = Test.get_react(html) 146 | 147 | assert react.slots == %{"default" => "Simple content"} 148 | end 149 | 150 | test "encodes slot as base64" do 151 | html = render_component(&component_with_inner_block/1) 152 | 153 | # Get raw data-slots attribute to verify base64 encoding 154 | doc = Floki.parse_fragment!(html) 155 | slots_attr = Floki.attribute(doc, "data-slots") 156 | 157 | slots = 158 | slots_attr 159 | |> Jason.decode!() 160 | |> Enum.map(fn {key, value} -> {key, Base.decode64!(value)} end) 161 | |> Enum.into(%{}) 162 | 163 | assert slots == %{"default" => "Simple content"} 164 | end 165 | 166 | test "handles empty slots" do 167 | html = 168 | render_component(fn assigns -> 169 | ~H""" 170 | <.react name="WithSlots" /> 171 | """ 172 | end) 173 | 174 | react = Test.get_react(html) 175 | 176 | assert react.slots == %{} 177 | end 178 | end 179 | end 180 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------