Packages

React inside Phoenix LiveView — async code splitting, client-side props diffing, and zero-config component discovery.

Retired package: Release invalid - Not production-ready

Current section

Files

Jump to
react_phx assets hooks.ts
Raw

assets/hooks.ts

import { createElement, type ComponentType } from "react";
import { createRoot, hydrateRoot, type Root } from "react-dom/client";
import { getComponentTree, getHookFunctions } from "./utils";
import { applyPatch, computeDiff } from "./jsonPatch";
import type {
ComponentModule,
ComponentsOrResolve,
HookProps,
HookState,
LiveViewHook,
PatchOp,
} from "./types";
// ---------------------------------------------------------------------------
// 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: string): string {
const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
return new TextDecoder().decode(bytes);
}
function getAttributeJson(
el: HTMLElement,
attributeName: string,
): Record<string, unknown> {
const data = el.getAttribute(attributeName);
return data ? (JSON.parse(data) as Record<string, unknown>) : {};
}
function getChildren(hook: LiveViewHook): React.ReactNode[] {
const dataSlots = getAttributeJson(hook.el, "data-slots") as Record<
string,
string | undefined
>;
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: unknown): value is ComponentType<any> {
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 as any).$$typeof === "symbol") return true;
// Class component
if (
typeof value === "function" &&
(value as any).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: Function): boolean {
// 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: string): string {
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<V>(
map: Record<string, V>,
name: string,
): V | undefined {
// 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: unknown): value is PromiseLike<unknown> {
return (
value != null &&
typeof (value as any).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: unknown): ComponentType<any> {
if (mod == null) throw new Error("Module is null");
// Has a default export — use it directly
if (
typeof mod === "object" &&
"default" in (mod as object) &&
(mod as ComponentModule).default
) {
return (mod as ComponentModule).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 as object).filter(k => k !== "__esModule");
if (exports.length === 1) {
return (mod as Record<string, any>)[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 as ComponentType<any>;
}
/**
* 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: ComponentsOrResolve,
name: string,
): Promise<ComponentType<any>> {
let entry: unknown;
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 as (name: string) => unknown)(name);
} else {
// Static / lazy component map — look up by name (with fuzzy matching)
entry = findComponent(input as Record<string, unknown>, 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 as Function)) {
// It's a loader — call it and await the Promise
const result = await (entry as () => Promise<unknown>)();
return unwrapModule(result);
}
// It's a React function component — return the function itself
return entry as ComponentType<any>;
}
// 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);
}
// ---------------------------------------------------------------------------
// Hook internal state
// ---------------------------------------------------------------------------
interface ReactHookInternal {
_prevProps: Record<string, unknown> | null;
/** Persistent props object — stream patches mutate this in place. */
_props: Record<string, unknown>;
_state: HookState;
_root: Root | null;
_Component: ComponentType<any> | null;
_pendingUpdates: (() => void)[];
_render(): void;
_applyUpdate(): void;
_mountWithComponent(Component: ComponentType<any>, mountStart: number): void;
}
/** Combined type: hook internal state + LiveView hook interface. */
type HookInstance = ReactHookInternal & LiveViewHook;
// ---------------------------------------------------------------------------
// 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: ComponentsOrResolve,
name: string,
): ComponentType<any> | null {
// Resolve functions require calling — can't determine sync/async upfront
if (typeof input === "function") return null;
const entry = findComponent(input as Record<string, unknown>, 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 as Function)) return null; // needs async
return entry as ComponentType<any>; // 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: ComponentsOrResolve) {
const ReactHook: ReactHookInternal & {
_render(this: HookInstance): void;
_applyUpdate(this: HookInstance): void;
_mountWithComponent(this: HookInstance, Component: ComponentType<any>, mountStart: number): void;
mounted(this: HookInstance): void;
updated(this: HookInstance): void;
reconnected(this: HookInstance): void;
destroyed(this: HookInstance): void;
} = {
_prevProps: null,
_props: {},
_state: "loading",
_root: null,
_Component: null,
_pendingUpdates: [],
_render(this: HookInstance) {
const props = {
...this._props,
...getHookFunctions(this),
} as Record<string, unknown> & HookProps;
const tree = getComponentTree(
this._Component!,
props,
getChildren(this),
);
this._root!.render(tree);
},
_applyUpdate(this: HookInstance) {
// 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) as PatchOp[];
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),
} as Record<string, unknown> & HookProps;
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(this: HookInstance, Component: ComponentType<any>, mountStart: number) {
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) as PatchOp[];
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),
} as Record<string, unknown> & HookProps;
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(this: HookInstance) {
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 as HookState) === "destroyed") return;
this._mountWithComponent(Component, mountStart);
},
(err) => {
console.error(`react_phx: Failed to resolve component "${componentName}":`, err);
},
);
},
updated(this: HookInstance) {
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(this: HookInstance) {
// 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) as PatchOp[];
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: HookInstance) {
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 };
}