Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ See [Conventional Commits](Https://conventionalcommits.org) for commit guideline

<!-- changelog -->

## [Unreleased]

### Breaking Changes:

* `data-props` is now encoded via the new `LiveReact.Encoder` protocol instead of `Jason.Encoder`. Struct props must now `@derive LiveReact.Encoder` (or implement it explicitly) — plain maps are unaffected. See `LiveReact.Encoder` moduledoc for migration examples.

### Features:

* Props are now diffed and sent incrementally over `data-props-diff` instead of being fully re-sent on every update (`config :live_react, enable_props_diff: true` by default; opt out globally with `false` or per-component with `diff={false}`).
* Added support for `Phoenix.LiveView.stream/3,4` assigns: any `%Phoenix.LiveView.LiveStream{}` value passed as a prop is now automatically diffed and delivered over `data-streams-diff`.

## [v1.1.0](https://github.com/mrdotb/live_react/compare/v1.0.1...v1.1.0) (2025-06-22)

### Features:
Expand Down
110 changes: 110 additions & 0 deletions assets/js/live_react/compactPatch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
export const decodeCompactPatch = (payload) => {
if (!payload) return [];

const operations = [];
let offset = 0;

while (offset < payload.length) {
const code = payload[offset++];

if (code === "n") {
offset = skipDigits(payload, offset);
continue;
}

const op = opFromCode(code);
const pathLength = readLength(payload, offset);
offset = pathLength.offset;

const path = payload.slice(offset, offset + pathLength.value);
offset += pathLength.value;

if (op === "remove") {
operations.push({ op, path });
continue;
}

const tag = payload[offset++];

if (tag === "z") {
operations.push({ op, path, value: null });
continue;
}

if (tag === "b") {
operations.push({ op, path, value: payload[offset++] === "1" });
continue;
}

const valueLength = readLength(payload, offset);
offset = valueLength.offset;

const rawValue = payload.slice(offset, offset + valueLength.value);
offset += valueLength.value;

if (tag === "n") {
operations.push({ op, path, value: Number(rawValue) });
} else if (tag === "s") {
operations.push({ op, path, value: rawValue });
} else if (tag === "J") {
operations.push({ op, path, value: decodeCompactJson(rawValue) });
} else {
throw new Error(`Unknown LiveReact patch value tag: ${tag}`);
}
}

return operations;
};

const opFromCode = (code) => {
switch (code) {
case "a":
return "add";
case "d":
return "remove";
case "r":
return "replace";
case "u":
return "upsert";
case "l":
return "limit";
default:
throw new Error(`Unknown LiveReact patch operation code: ${code}`);
}
};

const readLength = (payload, offset) => {
let value = 0;
let hasDigits = false;

while (offset < payload.length) {
const code = payload.charCodeAt(offset);
if (code < 48 || code > 57) break;
value = value * 10 + code - 48;
offset++;
hasDigits = true;
}

if (!hasDigits || payload[offset] !== ":") throw new Error("Invalid LiveReact patch length prefix");
return { value, offset: offset + 1 };
};

const skipDigits = (payload, offset) => {
while (offset < payload.length) {
const code = payload.charCodeAt(offset);
if (code < 48 || code > 57) break;
offset++;
}

return offset;
};

export const decodeCompactJson = (value) => {
return JSON.parse(
value.replace(/~~|~\^|\^/g, (match) => {
if (match === "~~") return "~";
if (match === "~^") return "^";
return '"';
}),
);
};
46 changes: 46 additions & 0 deletions assets/js/live_react/compactPatch.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { decodeCompactPatch } from "./compactPatch";

describe("decodeCompactPatch", () => {
it("decodes scalar add, replace, remove, and nonce operations", () => {
expect(decodeCompactPatch("n123r6:/countn1:6a8:/items/3s1:dd8:/items/0")).toEqual([
{ op: "replace", path: "/count", value: 6 },
{ op: "add", path: "/items/3", value: "d" },
{ op: "remove", path: "/items/0" },
]);
});

it("decodes caret-encoded JSON values", () => {
expect(decodeCompactPatch("a5:/rowsJ25:{^id^:3,^name^:^Charlie^}")).toEqual([
{ op: "add", path: "/rows", value: { id: 3, name: "Charlie" } },
]);
});

it("decodes escaped caret JSON values", () => {
expect(decodeCompactPatch("a5:/metaJ27:{^tilde^:^~~^,^caret^:^~^^}")).toEqual([
{ op: "add", path: "/meta", value: { tilde: "~", caret: "^" } },
]);
});

it("uses JavaScript string lengths for strings and paths", () => {
expect(decodeCompactPatch("r14:/profile/na.mes6:zażółćr6:/emojis2:🚀")).toEqual([
{ op: "replace", path: "/profile/na.me", value: "zażółć" },
{ op: "replace", path: "/emoji", value: "🚀" },
]);
});

it("decodes null, booleans, floats, upsert, and limit", () => {
expect(decodeCompactPatch("r6:/titlezu6:/itemsJ8:{^id^:1}l6:/itemsn2:-3r5:/flagb0r6:/pricen4:22.5")).toEqual([
{ op: "replace", path: "/title", value: null },
{ op: "upsert", path: "/items", value: { id: 1 } },
{ op: "limit", path: "/items", value: -3 },
{ op: "replace", path: "/flag", value: false },
{ op: "replace", path: "/price", value: 22.5 },
]);
});

it("returns an empty array for a null or empty payload", () => {
expect(decodeCompactPatch(null)).toEqual([]);
expect(decodeCompactPatch("")).toEqual([]);
});
});
75 changes: 64 additions & 11 deletions assets/js/live_react/hooks.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,23 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { getComponentTree } from "./utils";
import { decodeCompactJson, decodeCompactPatch } from "./compactPatch";
import { applyPatch } from "./jsonPatch";

function getAttributeJson(el, attributeName) {
const data = el.getAttribute(attributeName);
return data ? JSON.parse(data) : {};
}

function getDiff(el, attributeName) {
return decodeCompactPatch(el.getAttribute(attributeName));
}

function getBaseProps(el) {
const data = el.getAttribute("data-props");
return data ? decodeCompactJson(data) : {};
}

function getChildren(hook) {
const dataSlots = getAttributeJson(hook.el, "data-slots");

Expand All @@ -23,7 +34,8 @@ function getChildren(hook) {

function getProps(hook) {
return {
...getAttributeJson(hook.el, "data-props"),
...hook._props,
...hook._streams,
pushEvent: hook.pushEvent.bind(hook),
pushEventTo: hook.pushEventTo.bind(hook),
handleEvent: hook.handleEvent.bind(hook),
Expand All @@ -33,23 +45,49 @@ function getProps(hook) {
};
}

function refreshStreams(hook) {
hook._streams = applyPatch(
hook._streams,
getDiff(hook.el, "data-streams-diff"),
);
}

function refreshProps(hook) {
if (hook.el.getAttribute("data-use-diff") === "true") {
hook._props = applyPatch(hook._props, getDiff(hook.el, "data-props-diff"));
} else {
hook._props = getBaseProps(hook.el);
}
}

// Renders via a module-level function (rather than a `this._render()` method
// on the hook object) so it works regardless of how `mounted`/`updated`/
// `reconnected` are invoked. In production, LiveView's ViewHook merges every
// key of the hook definition (including a `_render` method) onto the same
// instance before calling any callback, so `this._render()` would resolve —
// but that merging is an internal LiveView implementation detail, not part
// of the public hook contract, so we don't rely on it here.
function render(hook) {
const tree = getComponentTree(
hook._Component,
getProps(hook),
getChildren(hook),
);
hook._root.render(tree);
}

export function getHooks(components) {
const ReactHook = {
_render() {
const tree = getComponentTree(
this._Component,
getProps(this),
getChildren(this),
);
this._root.render(tree);
},
mounted() {
const componentName = this.el.getAttribute("data-name");
if (!componentName) {
throw new Error("Component name must be provided");
}

this._Component = components[componentName];
this._props = getBaseProps(this.el);
this._streams = {};
refreshStreams(this);

const isSSR = this.el.hasAttribute("data-ssr");

Expand All @@ -62,12 +100,27 @@ export function getHooks(components) {
this._root = ReactDOM.hydrateRoot(this.el, tree);
} else {
this._root = ReactDOM.createRoot(this.el);
this._render();
render(this);
}
},
updated() {
if (this._root) {
this._render();
refreshProps(this);
refreshStreams(this);
render(this);
}
},
reconnected() {
if (this._root) {
// Unlike updated(), always do a full props resync from the current
// data-props attribute rather than applying data-props-diff. On
// reconnect the LiveView process is fresh (assigns.__changed__ ==
// nil), so the server-computed diff is a no-op even though state may
// have advanced while disconnected — but the full snapshot in
// data-props is always current, so read that directly.
this._props = getBaseProps(this.el);
refreshStreams(this);
render(this);
}
},
destroyed() {
Expand Down
Loading
Loading