Packages
React inside Phoenix LiveView — async code splitting, client-side props diffing, and zero-config component discovery.
Retired package: Release invalid - Not production-ready, LiveView 1.0.x compatibility issue
Current section
Files
Jump to
Current section
Files
assets/dist/hooks.js
import { createElement } from "react";
import { createRoot, hydrateRoot } from "react-dom/client";
import { getComponentTree, getHookFunctions } from "./utils";
import { applyPatch, computeDiff } from "./jsonPatch";
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/**
* Decode a Base64-encoded string as UTF-8.
*
* `atob()` decodes to a Latin-1 binary string, which corrupts non-ASCII
* characters (Chinese, emoji, accented letters). This function first
* converts to bytes, then uses `TextDecoder` for proper UTF-8 handling.
*/
export function decodeBase64Utf8(base64) {
const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
return new TextDecoder().decode(bytes);
}
function getAttributeJson(el, attributeName) {
const data = el.getAttribute(attributeName);
return data ? JSON.parse(data) : {};
}
function getChildren(hook) {
const dataSlots = getAttributeJson(hook.el, "data-slots");
if (!dataSlots?.default) {
return [];
}
return [
createElement("div", {
dangerouslySetInnerHTML: { __html: decodeBase64Utf8(dataSlots.default).trim() },
}),
];
}
// ---------------------------------------------------------------------------
// Component detection & resolution
// ---------------------------------------------------------------------------
/**
* Determine whether a value is a React component (as opposed to an async
* loader function like `() => import("./Counter")`).
*
* Heuristic:
* 1. Has `$$typeof` — it is a React element/forward-ref/memo wrapper.
* 2. Has `prototype.isReactComponent` — it is a class component.
* 3. Otherwise, if it is a function, treat it as a loader.
*/
export function isReactComponent(value) {
if (typeof value !== "function" && typeof value !== "object")
return false;
if (value === null)
return false;
// React.memo, React.forwardRef, React.lazy — they carry $$typeof
if (typeof value.$$typeof === "symbol")
return true;
// Class component
if (typeof value === "function" &&
value.prototype?.isReactComponent) {
return true;
}
// Not a function at all — cannot be a loader, treat as component-ish object
if (typeof value !== "function")
return false;
return false;
}
/**
* Detect whether a function is an async loader (e.g. `() => import("./X")`)
* **without calling it**.
*
* `import.meta.glob` with `eager: false` produces anonymous, zero-arity
* arrow functions. React function components almost always have names
* (the function declaration or variable name). We exploit this difference:
*
* - `fn.name === ""` AND `fn.length === 0` → async loader (anonymous glob output)
*
* Named functions — even zero-arg arrows like `const Counter = () => <div/>`
* — have `fn.name === "Counter"` and must NOT be treated as loaders.
* Minified code may strip names, but that is handled by the user providing
* an explicit `getHooks` config with direct component references.
*/
export function isAsyncLoader(fn) {
// import.meta.glob with eager:false produces zero-arity functions.
// In dev mode they're anonymous (name=""). In production builds,
// Vite may assign the glob key as the function name (e.g., "/react-components/pages/Counter.tsx").
// React components have simple PascalCase names, never file paths.
if (fn.length !== 0)
return false;
if (fn.name === "")
return true;
// Name contains "/" or "." with extension → it's a file path from glob, not a component name
if (fn.name.includes("/") || /\.\w+$/.test(fn.name))
return true;
return false;
}
/**
* Convert a kebab-case or snake_case string to PascalCase.
* e.g. "lectures-page" → "LecturesPage", "admin_panel" → "AdminPanel"
*/
export function toPascalCase(str) {
return str.replace(/(^|[-_])([a-z])/g, (_, _sep, c) => c.toUpperCase());
}
/**
* Fuzzy-match a component name against a map whose keys may be glob-style
* paths (e.g. `./pages/lectures-page/lectures-page.tsx`).
*
* Tries:
* 1. Exact match
* 2. Filename-without-extension match
* 3. Filename kebab→PascalCase match (e.g. "lectures-page" → "LecturesPage")
*/
export function findComponent(map, name) {
// 1. Exact match
if (name in map)
return map[name];
// 2-3. Filename match: strip path and extension, then try exact and PascalCase
for (const key of Object.keys(map)) {
const filename = key.split("/").pop()?.replace(/\.[^.]+$/, "");
if (!filename)
continue;
// 2. Direct filename match
if (filename === name)
return map[key];
// 3. kebab/snake → PascalCase match
if (toPascalCase(filename) === name)
return map[key];
}
return undefined;
}
/**
* Check whether a value is a thenable (has a `.then` method).
*/
function isThenable(value) {
return (value != null &&
typeof value.then === "function");
}
/**
* Unwrap a resolved module: if it has `.default`, return that; otherwise
* try to find a single named export that looks like a component.
*
* This handles both `export default function Counter()` and
* `export function Counter()` (named export, no default).
*/
function unwrapModule(mod) {
if (mod == null)
throw new Error("Module is null");
// Has a default export — use it directly
if (typeof mod === "object" &&
"default" in mod &&
mod.default) {
return mod.default;
}
// No default export — try to find a single named export that looks like a component
if (typeof mod === "object") {
const exports = Object.keys(mod).filter(k => k !== "__esModule");
if (exports.length === 1) {
return mod[exports[0]];
}
}
// Multiple named exports or a plain value — return as-is
// (will fail at React render time with a clear error if it's not a component)
return mod;
}
/**
* Resolve a component from the input (static map, async loader map, or
* resolve function). Returns the resolved `ComponentType`.
*
* This function is **async** — it awaits any loaders or promises.
*/
export async function resolveComponent(input, name) {
let entry;
if (typeof input === "function") {
// `input` is a ResolveFn — call it with the name.
// The result can be a component, a Promise<module>, or a module object.
entry = input(name);
}
else {
// Static / lazy component map — look up by name (with fuzzy matching)
entry = findComponent(input, name);
}
if (entry == null) {
throw new Error(`Component "${name}" not found`);
}
// If entry is already a recognized React component (class, memo, forwardRef),
// return it directly.
if (isReactComponent(entry)) {
return entry;
}
// If entry is a function, it could be:
// a) A plain React function component (returns JSX)
// b) An async loader like () => import("./Counter") (returns Promise)
//
// We distinguish using a heuristic that does NOT call the function.
// Calling a React component outside of React's render cycle would crash
// if it uses Hooks or expects required props.
if (typeof entry === "function") {
if (isAsyncLoader(entry)) {
// It's a loader — call it and await the Promise
const result = await entry();
return unwrapModule(result);
}
// It's a React function component — return the function itself
return entry;
}
// If entry is a thenable (Promise from a resolve function), await it
if (isThenable(entry)) {
const resolved = await entry;
return unwrapModule(resolved);
}
return unwrapModule(entry);
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Try to resolve a component synchronously from a static map.
* Returns the component if found synchronously, or null if async resolution
* is needed (resolve functions, async loaders, thenables).
*/
export function resolveComponentSync(input, name) {
// Resolve functions require calling — can't determine sync/async upfront
if (typeof input === "function")
return null;
const entry = findComponent(input, name);
if (entry == null)
return null;
// React.memo, React.forwardRef, React.lazy — return directly
if (isReactComponent(entry))
return entry;
// Function: check if it's a sync component or an async loader
if (typeof entry === "function") {
if (isAsyncLoader(entry))
return null; // needs async
return entry; // plain React function component
}
// Thenable or module object — needs async unwrapping
if (isThenable(entry))
return null;
// Try to unwrap as module (has .default)
try {
return unwrapModule(entry);
}
catch {
return null;
}
}
/**
* Create Phoenix LiveView hooks that bridge to React components.
*
* Supports three input shapes:
* 1. Static object: `getHooks({ Counter, Chart })`
* 2. Async loader functions: `getHooks({ Counter: () => import("./Counter") })`
* 3. Resolve function: `getHooks((name) => import("./components/" + name))`
*/
export function getHooks(input) {
const ReactHook = {
_prevProps: null,
_props: {},
_state: "loading",
_root: null,
_Component: null,
_pendingUpdates: [],
_render() {
const props = {
...this._props,
...getHookFunctions(this),
};
const tree = getComponentTree(this._Component, props, getChildren(this));
this._root.render(tree);
},
_applyUpdate() {
// Read the latest data-only props from the DOM attribute
const newProps = getAttributeJson(this.el, "data-props");
// Diff against previous data props to detect actual changes
const patches = computeDiff(this._prevProps ?? {}, newProps);
if (patches.length > 0) {
// Apply only the changed fields to the live props object
applyPatch(this._props, patches);
// Break reference identity so React detects changes via memo/useMemo
this._props = { ...this._props };
}
// Apply stream patches on top of props (mutates _props in place)
const streamsDiff = this.el.getAttribute("data-streams-diff");
if (streamsDiff) {
const streamPatches = JSON.parse(streamsDiff);
applyPatch(this._props, streamPatches);
// Break reference identity so React detects changes
this._props = { ...this._props };
}
// Snapshot current data props for the next diff cycle
this._prevProps = { ...newProps };
// Always re-render — React will reconcile efficiently via its VDOM
if (!this._root || !this._Component)
return;
const children = getChildren(this);
// Merge hook functions on top for the render (they are not in data-props)
const renderProps = {
...this._props,
...getHookFunctions(this),
};
const tree = getComponentTree(this._Component, renderProps, children);
this._root.render(tree);
// Track render count for M4 (selective updates) metric
const renderCount = parseInt(this.el.getAttribute("data-render-count") || "0");
this.el.setAttribute("data-render-count", String(renderCount + 1));
},
_mountWithComponent(Component, mountStart) {
this._Component = Component;
// Initialize _props from data-props and snapshot for future diffs
this._props = getAttributeJson(this.el, "data-props");
this._prevProps = { ...this._props };
const initialStreamsDiff = this.el.getAttribute("data-streams-diff");
if (initialStreamsDiff) {
const patches = JSON.parse(initialStreamsDiff);
applyPatch(this._props, patches);
}
// Decide root mode
const isSSR = this.el.getAttribute("data-ssr") === "true";
let didHydrate = false;
if (isSSR && this._pendingUpdates.length === 0) {
const props = {
...this._props,
...getHookFunctions(this),
};
const tree = getComponentTree(Component, props, getChildren(this));
this._root = hydrateRoot(this.el, tree);
didHydrate = true;
}
else if (isSSR && this._pendingUpdates.length > 0) {
this.el.innerHTML = "";
this._root = createRoot(this.el);
this._render();
}
else {
this._root = createRoot(this.el);
this._render();
}
// Record performance instrumentation
const mountEnd = performance.now();
this.el.setAttribute("data-mount-ms", String(Math.round(mountEnd - mountStart)));
this.el.setAttribute("data-hydration", String(didHydrate));
this.el.setAttribute("data-render-count", "0");
// Set mounted state
this._state = "mounted";
// Apply any pending updates that arrived during async loading
for (const applyUpdate of this._pendingUpdates) {
applyUpdate();
}
this._pendingUpdates = [];
},
mounted() {
const mountStart = performance.now();
this._state = "loading";
this._pendingUpdates = [];
const componentName = this.el.getAttribute("data-name");
if (!componentName) {
throw new Error("Component name must be provided");
}
// Fast path: synchronous resolution for static component maps.
// This keeps mounting instant during LiveView navigation — no
// microtask delay, no visual gap between old and new page.
const syncComponent = resolveComponentSync(input, componentName);
if (syncComponent) {
this._mountWithComponent(syncComponent, mountStart);
return;
}
// Slow path: async resolution for lazy loaders / resolve functions.
// Component renders after the chunk loads.
// Returns the Promise so callers (tests) can await; LiveView ignores it.
return resolveComponent(input, componentName).then((Component) => {
if (this._state === "destroyed")
return;
this._mountWithComponent(Component, mountStart);
}, (err) => {
console.error(`react_phx: Failed to resolve component "${componentName}":`, err);
});
},
updated() {
if (this._state === "loading") {
// Queue update to apply after mounting completes
this._pendingUpdates.push(() => this._applyUpdate());
return;
}
if (this._state === "mounted" && this._root) {
this._applyUpdate();
}
},
reconnected() {
// Full reset: re-read canonical props from DOM, discard accumulated
// stream state that may be stale after a WebSocket reconnect.
const freshProps = getAttributeJson(this.el, "data-props");
this._props = { ...freshProps };
this._prevProps = {};
// Re-apply any stream diffs present in the reconnect render
const streamsDiff = this.el.getAttribute("data-streams-diff");
if (streamsDiff) {
const patches = JSON.parse(streamsDiff);
applyPatch(this._props, patches);
}
// Re-render with fresh state
if (this._root && this._Component) {
const mergedProps = { ...this._props, ...getHookFunctions(this) };
const children = getChildren(this);
const tree = getComponentTree(this._Component, mergedProps, children);
this._root.render(tree);
}
},
destroyed() {
this._state = "destroyed";
if (this._root) {
// Defer unmount until after LiveView's page transition completes.
// Unmounting React immediately during a live navigation tears down
// the component tree while LiveView is still patching the DOM,
// which can cause a full page reload instead of a smooth transition.
const root = this._root;
this._root = null;
window.addEventListener("phx:page-loading-stop", () => root.unmount(), { once: true });
}
},
};
return { ReactHook };
}