diff --git a/CHANGELOG.md b/CHANGELOG.md index c1d437eb..b828b71e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ See [Conventional Commits](Https://conventionalcommits.org) for commit guideline +## [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: diff --git a/assets/js/live_react/compactPatch.js b/assets/js/live_react/compactPatch.js new file mode 100644 index 00000000..e4026895 --- /dev/null +++ b/assets/js/live_react/compactPatch.js @@ -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 '"'; + }), + ); +}; diff --git a/assets/js/live_react/compactPatch.test.js b/assets/js/live_react/compactPatch.test.js new file mode 100644 index 00000000..5ac9f707 --- /dev/null +++ b/assets/js/live_react/compactPatch.test.js @@ -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([]); + }); +}); diff --git a/assets/js/live_react/hooks.js b/assets/js/live_react/hooks.js index b43b3906..dd98b5bb 100644 --- a/assets/js/live_react/hooks.js +++ b/assets/js/live_react/hooks.js @@ -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"); @@ -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), @@ -33,16 +45,39 @@ 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) { @@ -50,6 +85,9 @@ export function getHooks(components) { } this._Component = components[componentName]; + this._props = getBaseProps(this.el); + this._streams = {}; + refreshStreams(this); const isSSR = this.el.hasAttribute("data-ssr"); @@ -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() { diff --git a/assets/js/live_react/hooks.test.js b/assets/js/live_react/hooks.test.js new file mode 100644 index 00000000..3d203da1 --- /dev/null +++ b/assets/js/live_react/hooks.test.js @@ -0,0 +1,222 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createMockLiveViewHook } from "./tests/helpers"; + +const renderMock = vi.fn(); +const rootMock = { render: renderMock, unmount: vi.fn() }; + +vi.mock("react-dom/client", () => ({ + default: { + createRoot: vi.fn(() => rootMock), + hydrateRoot: vi.fn(() => rootMock), + }, +})); + +const TestComponent = () => null; + +function lastRenderedProps() { + // The rendered tree is + const tree = renderMock.mock.calls.at(-1)[0]; + return tree.props.children.props; +} + +// Minimal test-only encoder mirroring LiveReact.Patch.serialize/1 and +// LiveReact.Patch.encode_object/1, so fixtures can't drift from hand-typed +// wire strings. Exercised indirectly by decodeCompactPatch/decodeCompactJson +// (already unit-tested against real Elixir-produced fixtures in Task 9). +const OP_CODES = { + add: "a", + remove: "d", + replace: "r", + upsert: "u", + limit: "l", +}; + +function encodeValue(value) { + if (value === null) return "z"; + if (value === true) return "b1"; + if (value === false) return "b0"; + if (typeof value === "number") { + const s = String(value); + return `n${s.length}:${s}`; + } + if (typeof value === "string") return `s${value.length}:${value}`; + const json = JSON.stringify(value).replace(/"/g, "^"); + return `J${json.length}:${json}`; +} + +function encodePatch(ops) { + return ops + .map(([op, path, value]) => { + const prefix = `${OP_CODES[op]}${path.length}:${path}`; + return op === "remove" ? prefix : prefix + encodeValue(value); + }) + .join(""); +} + +function encodeProps(props) { + return JSON.stringify(props).replace(/"/g, "^"); +} + +describe("ReactHook", () => { + let getHooks; + let ReactHook; + + beforeEach(async () => { + vi.resetModules(); + renderMock.mockClear(); + ({ getHooks } = await import("./hooks")); + ({ ReactHook } = getHooks({ TestComponent })); + }); + + it("merges base props and streams on mount", () => { + const hook = createMockLiveViewHook({ + "data-name": "TestComponent", + "data-props": encodeProps({ title: "Hello" }), + "data-streams-diff": encodePatch([ + ["replace", "/users", []], + ["upsert", "/users/-", { __dom_id: "u1" }], + ]), + }); + + ReactHook.mounted.call(hook); + + const props = lastRenderedProps(); + expect(props.title).toBe("Hello"); + expect(props.users).toEqual([{ __dom_id: "u1" }]); + }); + + it("applies props_diff on update when data-use-diff is true", () => { + const hook = createMockLiveViewHook({ + "data-name": "TestComponent", + "data-props": encodeProps({ title: "Hello" }), + "data-use-diff": "true", + }); + + ReactHook.mounted.call(hook); + + hook.el.getAttribute.mockImplementation((name) => { + if (name === "data-use-diff") return "true"; + if (name === "data-props-diff") + return encodePatch([["replace", "/title", "World"]]); + if (name === "data-streams-diff") return null; + return null; + }); + + ReactHook.updated.call(hook); + + expect(lastRenderedProps().title).toBe("World"); + }); + + it("replaces props wholesale on update when data-use-diff is false", () => { + const hook = createMockLiveViewHook({ + "data-name": "TestComponent", + "data-props": encodeProps({ title: "Hello" }), + "data-use-diff": "false", + }); + + ReactHook.mounted.call(hook); + + hook.el.getAttribute.mockImplementation((name) => { + if (name === "data-use-diff") return "false"; + if (name === "data-props") return encodeProps({ title: "Replaced" }); + if (name === "data-streams-diff") return null; + return null; + }); + + ReactHook.updated.call(hook); + + expect(lastRenderedProps().title).toBe("Replaced"); + }); + + it("accumulates stream inserts across updates without losing prior items", () => { + const hook = createMockLiveViewHook({ + "data-name": "TestComponent", + "data-props": encodeProps({}), + "data-streams-diff": encodePatch([ + ["replace", "/users", []], + ["upsert", "/users/-", { __dom_id: "u1" }], + ]), + }); + + ReactHook.mounted.call(hook); + + hook.el.getAttribute.mockImplementation((name) => { + if (name === "data-use-diff") return "true"; + if (name === "data-props-diff") return null; + if (name === "data-streams-diff") + return encodePatch([["upsert", "/users/-", { __dom_id: "u2" }]]); + return null; + }); + + ReactHook.updated.call(hook); + + expect(lastRenderedProps().users).toEqual([ + { __dom_id: "u1" }, + { __dom_id: "u2" }, + ]); + }); + + it("reconnected() resyncs streams via diff, same as updated()", () => { + const hook = createMockLiveViewHook({ + "data-name": "TestComponent", + "data-props": encodeProps({ title: "Hello" }), + "data-streams-diff": encodePatch([ + ["replace", "/users", []], + ["upsert", "/users/-", { __dom_id: "u1" }], + ]), + }); + + ReactHook.mounted.call(hook); + + hook.el.getAttribute.mockImplementation((name) => { + if (name === "data-use-diff") return "true"; + if (name === "data-props") return encodeProps({ title: "Hello" }); + if (name === "data-props-diff") return null; + if (name === "data-streams-diff") + return encodePatch([["upsert", "/users/-", { __dom_id: "u2" }]]); + return null; + }); + + ReactHook.reconnected.call(hook); + + expect(lastRenderedProps().users).toEqual([ + { __dom_id: "u1" }, + { __dom_id: "u2" }, + ]); + }); + + it("reconnected() does a full props resync from data-props instead of applying a diff", () => { + // Regression test: on a real LiveView reconnect the server process is + // fresh (assigns.__changed__ == nil), so calculate_props_diff/2 produces + // an EMPTY diff regardless of how much server state actually changed + // while disconnected. The full, current snapshot is sent via data-props + // instead. reconnected() must read that fresh snapshot rather than + // applying the (necessarily empty) data-props-diff to the stale + // in-memory props, or the component would keep showing stale data after + // reconnecting. + const hook = createMockLiveViewHook({ + "data-name": "TestComponent", + "data-props": encodeProps({ title: "Hello" }), + }); + + ReactHook.mounted.call(hook); + expect(lastRenderedProps().title).toBe("Hello"); + + hook.el.getAttribute.mockImplementation((name) => { + if (name === "data-use-diff") return "true"; + // Fresh full snapshot reflecting server state that advanced while + // disconnected... + if (name === "data-props") return encodeProps({ title: "World" }); + // ...paired with the empty diff the server actually sends on + // reconnect (init = true means calculate_props_diff/2 has nothing to + // report). + if (name === "data-props-diff") return encodePatch([]); + if (name === "data-streams-diff") return null; + return null; + }); + + ReactHook.reconnected.call(hook); + + expect(lastRenderedProps().title).toBe("World"); + }); +}); diff --git a/assets/js/live_react/jsonPatch.js b/assets/js/live_react/jsonPatch.js new file mode 100644 index 00000000..063094fe --- /dev/null +++ b/assets/js/live_react/jsonPatch.js @@ -0,0 +1,269 @@ +function unescapePathComponent(path) { + return path.replace(/~1/g, "/").replace(/~0/g, "~"); +} + +function resolvePathComponent(component, arrayObj) { + if (!component.startsWith("$$")) { + return component; + } + + const targetId = component.substring(2); + const index = arrayObj.findIndex( + (item) => item && typeof item === "object" && item.__dom_id == targetId, + ); + + if (index === -1) { + console.warn( + `JSON Patch: Item with __dom_id "${targetId}" not found in array, skipping operation`, + ); + return null; + } + + return index.toString(); +} + +function readPathSegment(path, start, end) { + const segment = path.slice(start, end); + return segment.indexOf("~") === -1 ? segment : unescapePathComponent(segment); +} + +function resolveArrayIndex(key, arrayObj, allowAppend) { + if (key.startsWith("$$")) { + const resolved = resolvePathComponent(key, arrayObj); + return resolved === null ? null : parseInt(resolved, 10); + } + + if (key === "-") return allowAppend ? arrayObj.length : arrayObj.length - 1; + return parseInt(key, 10); +} + +export function getValueByPointer(document, pointer) { + if (pointer === "") return document; + + const keys = pointer.split("/").slice(1); + let obj = document; + + for (const key of keys) { + let resolvedKey = + key.indexOf("~") !== -1 ? unescapePathComponent(key) : key; + + if (Array.isArray(obj)) { + if (resolvedKey.startsWith("$$")) { + const resolved = resolvePathComponent(resolvedKey, obj); + if (resolved === null) return undefined; + resolvedKey = resolved; + } + obj = + obj[resolvedKey === "-" ? obj.length - 1 : parseInt(resolvedKey, 10)]; + } else { + obj = obj[resolvedKey]; + } + } + + return obj; +} + +/** + * Apply a single patch operation on a JSON document in-place. + */ +export function applyOperation(document, operation) { + return applyPatchOperation( + document, + operation.op, + operation.path, + operation.value, + ); +} + +function applyPatchOperation(document, op, path, value) { + if (path === "") { + switch (op) { + case "add": + case "replace": + return value; + case "test": + return document; + case "remove": + return null; + } + } + + let obj = document; + let segmentStart = 1; + + while (true) { + const segmentEnd = path.indexOf("/", segmentStart); + if (segmentEnd === -1) break; + + const key = readPathSegment(path, segmentStart, segmentEnd); + + if (Array.isArray(obj)) { + const index = resolveArrayIndex(key, obj, false); + if (index === null) return document; + obj = obj[index]; + } else { + obj = obj[key]; + } + + segmentStart = segmentEnd + 1; + } + + const unescapedKey = readPathSegment(path, segmentStart, path.length); + + if (Array.isArray(obj)) { + const resolvedIndex = resolveArrayIndex(unescapedKey, obj, true); + if (resolvedIndex === null) return document; + const index = resolvedIndex; + + switch (op) { + case "add": + obj.splice(index, 0, value); + break; + case "remove": + obj.splice(index, 1); + break; + case "replace": + obj[index] = value; + break; + case "upsert": + if (value && typeof value === "object" && "__dom_id" in value) { + const existingIndex = obj.findIndex( + (item) => + item && + typeof item === "object" && + item.__dom_id === value.__dom_id, + ); + + if (existingIndex !== -1) { + obj[existingIndex] = value; + } else { + obj.splice(index, 0, value); + } + } else { + obj.splice(index, 0, value); + } + break; + case "test": + break; + case "limit": + if (value >= 0) { + if (value < obj.length) obj.splice(value); + } else { + const keepCount = Math.abs(value); + if (keepCount < obj.length) obj.splice(0, obj.length - keepCount); + } + break; + } + } else { + switch (op) { + case "add": + case "replace": + obj[unescapedKey] = value; + break; + case "remove": + delete obj[unescapedKey]; + break; + case "test": + break; + case "limit": + const targetArray = obj[unescapedKey]; + if (Array.isArray(targetArray)) { + if (value >= 0) { + if (value < targetArray.length) targetArray.splice(value); + } else { + const keepCount = Math.abs(value); + if (keepCount < targetArray.length) + targetArray.splice(0, targetArray.length - keepCount); + } + } + break; + } + } + + return document; +} + +function cloneShallow(value) { + if (Array.isArray(value)) return value.slice(); + if (value && typeof value === "object") return { ...value }; + return value; +} + +/** + * Clones every container from `document` down to (but not including) the + * value that `path`'s final segment addresses, the first time this patch + * batch touches each one — so the eventual write, wherever in + * applyPatchOperation's own traversal it lands, always mutates a + * this-batch-fresh object/array instead of aliasing a value some other + * live reference (a prior render's props, a memoized child's captured + * item) still points to. + * + * Segment 1 is always a clone candidate, even for single-segment paths + * like `/users` — that's what `applyPatchOperation`'s top-level splice/ + * assign (e.g. the `limit` operation) mutates directly. Segments 2..N-1 + * extend the same guarantee to nested paths like `/items/1/name`, where + * both `document.items` (the array) and `document.items[1]` (the item) + * need fresh references before the write two levels down. + * + * The final segment itself is deliberately left uncloned: cloning the + * value living there would corrupt append-style inserts (a path ending in + * `/-`, since there's no existing value at the append index yet), and + * every other operation either overwrites that slot outright or mutates + * its already-fresh parent — neither needs the old value pre-cloned. + */ +function ensureWritablePath(document, path, touchedPaths) { + let obj = document; + let segmentStart = 1; + let prefix = ""; + + while (true) { + const segmentEnd = path.indexOf("/", segmentStart); + if (segmentEnd === -1) break; + + const key = readPathSegment(path, segmentStart, segmentEnd); + let resolvedKey = key; + + if (Array.isArray(obj)) { + const index = resolveArrayIndex(key, obj, false); + if (index === null) return; + resolvedKey = index; + } + + prefix += "/" + resolvedKey; + + if (!touchedPaths.has(prefix)) { + touchedPaths.add(prefix); + obj[resolvedKey] = cloneShallow(obj[resolvedKey]); + } + + obj = obj[resolvedKey]; + segmentStart = segmentEnd + 1; + } + + if (prefix === "") { + const key = readPathSegment(path, segmentStart, path.length); + const singleSegmentPrefix = "/" + key; + + if (!touchedPaths.has(singleSegmentPrefix)) { + touchedPaths.add(singleSegmentPrefix); + obj[key] = cloneShallow(obj[key]); + } + } +} + +export function applyPatch(document, patch) { + let result = document; + const touchedPaths = new Set(); + + for (const operation of patch) { + if (operation.path === "") { + result = applyOperation(result, operation); + continue; + } + + ensureWritablePath(result, operation.path, touchedPaths); + applyOperation(result, operation); + } + + return result; +} diff --git a/assets/js/live_react/jsonPatch.test.js b/assets/js/live_react/jsonPatch.test.js new file mode 100644 index 00000000..9a432b8a --- /dev/null +++ b/assets/js/live_react/jsonPatch.test.js @@ -0,0 +1,240 @@ +import { describe, it, expect } from "vitest"; +import { getValueByPointer, applyOperation, applyPatch } from "./jsonPatch"; + +describe("getValueByPointer", () => { + const testDoc = { + foo: "bar", + baz: [1, 2, 3], + nested: { key: "value" }, + }; + + it("returns the root document for an empty pointer", () => { + expect(getValueByPointer(testDoc, "")).toBe(testDoc); + }); + + it("gets a simple property", () => { + expect(getValueByPointer(testDoc, "/foo")).toBe("bar"); + }); + + it("gets an array element by index", () => { + expect(getValueByPointer(testDoc, "/baz/1")).toBe(2); + }); + + it("gets the last array element with -", () => { + expect(getValueByPointer(testDoc, "/baz/-")).toBe(3); + }); + + it("gets a nested property", () => { + expect(getValueByPointer(testDoc, "/nested/key")).toBe("value"); + }); +}); + +describe("applyOperation", () => { + it("adds a property to an object", () => { + const doc = { foo: "bar" }; + applyOperation(doc, { op: "add", path: "/baz", value: "qux" }); + expect(doc).toEqual({ foo: "bar", baz: "qux" }); + }); + + it("replaces a property in an object", () => { + const doc = { foo: "bar" }; + applyOperation(doc, { op: "replace", path: "/foo", value: "baz" }); + expect(doc).toEqual({ foo: "baz" }); + }); + + it("removes a property from an object", () => { + const doc = { foo: "bar", baz: "qux" }; + applyOperation(doc, { op: "remove", path: "/foo" }); + expect(doc).toEqual({ baz: "qux" }); + }); + + it("adds an element to an array at a specific index", () => { + const doc = { arr: [1, 2, 3] }; + applyOperation(doc, { op: "add", path: "/arr/1", value: "new" }); + expect(doc).toEqual({ arr: [1, "new", 2, 3] }); + }); + + it("adds an element to the end of an array with -", () => { + const doc = { arr: [1, 2, 3] }; + applyOperation(doc, { op: "add", path: "/arr/-", value: "new" }); + expect(doc).toEqual({ arr: [1, 2, 3, "new"] }); + }); + + it("removes an element from an array", () => { + const doc = { arr: [1, 2, 3] }; + applyOperation(doc, { op: "remove", path: "/arr/1" }); + expect(doc).toEqual({ arr: [1, 3] }); + }); + + it("resolves $$dom_id in array paths for upsert (update)", () => { + const doc = { + rows: [ + { __dom_id: "a", v: 1 }, + { __dom_id: "b", v: 2 }, + ], + }; + applyOperation(doc, { + op: "upsert", + path: "/rows/-", + value: { __dom_id: "a", v: 99 }, + }); + expect(doc.rows).toEqual([ + { __dom_id: "a", v: 99 }, + { __dom_id: "b", v: 2 }, + ]); + }); + + it("resolves $$dom_id in array paths for upsert (insert)", () => { + const doc = { rows: [{ __dom_id: "a", v: 1 }] }; + applyOperation(doc, { + op: "upsert", + path: "/rows/-", + value: { __dom_id: "c", v: 3 }, + }); + expect(doc.rows).toEqual([ + { __dom_id: "a", v: 1 }, + { __dom_id: "c", v: 3 }, + ]); + }); + + it("removes an array element by $$dom_id", () => { + const doc = { rows: [{ __dom_id: "a" }, { __dom_id: "b" }] }; + applyOperation(doc, { op: "remove", path: "/rows/$$a" }); + expect(doc.rows).toEqual([{ __dom_id: "b" }]); + }); + + it("applies limit by trimming from the end for positive values", () => { + const doc = { rows: [1, 2, 3, 4] }; + applyOperation(doc, { op: "limit", path: "/rows", value: 2 }); + expect(doc.rows).toEqual([1, 2]); + }); + + it("applies limit by trimming from the start for negative values", () => { + const doc = { rows: [1, 2, 3, 4] }; + applyOperation(doc, { op: "limit", path: "/rows", value: -2 }); + expect(doc.rows).toEqual([3, 4]); + }); +}); + +describe("applyPatch copy-on-write", () => { + it("gives a fresh array reference for a touched top-level key", () => { + const original = { users: [{ __dom_id: "a", name: "Alice" }] }; + const usersRefBefore = original.users; + + const result = applyPatch(original, [ + { op: "upsert", path: "/users/-", value: { __dom_id: "b", name: "Bob" } }, + ]); + + expect(result.users).not.toBe(usersRefBefore); + expect(result.users).toEqual([ + { __dom_id: "a", name: "Alice" }, + { __dom_id: "b", name: "Bob" }, + ]); + }); + + it("keeps untouched top-level keys reference-identical", () => { + const original = { + users: [{ __dom_id: "a" }], + posts: [{ __dom_id: "p1" }], + }; + const postsRefBefore = original.posts; + + const result = applyPatch(original, [{ op: "remove", path: "/users/$$a" }]); + + expect(result.posts).toBe(postsRefBefore); + }); + + it("keeps unaffected items reference-identical while replacing changed ones", () => { + const alice = { __dom_id: "a", name: "Alice" }; + const bob = { __dom_id: "b", name: "Bob" }; + const original = { users: [alice, bob] }; + + const result = applyPatch(original, [ + { + op: "replace", + path: "/users/$$b", + value: { __dom_id: "b", name: "New Bob" }, + }, + ]); + + expect(result.users[0]).toBe(alice); + expect(result.users[1]).not.toBe(bob); + expect(result.users[1]).toEqual({ __dom_id: "b", name: "New Bob" }); + }); + + it("only clones a key once across multiple ops in the same batch", () => { + const original = { users: [{ __dom_id: "a" }] }; + + const result = applyPatch(original, [ + { op: "upsert", path: "/users/-", value: { __dom_id: "b" } }, + { op: "upsert", path: "/users/-", value: { __dom_id: "c" } }, + ]); + + expect(result.users.map((u) => u.__dom_id)).toEqual(["a", "b", "c"]); + }); + + it("mutates and returns the same document object (bag identity is stable)", () => { + const original = { users: [] }; + const result = applyPatch(original, [ + { op: "upsert", path: "/users/-", value: { __dom_id: "a" } }, + ]); + expect(result).toBe(original); + }); + + it("gives item-level identity to a field-level replace inside an array element", () => { + const item0 = { id: 1, name: "old" }; + const item1 = { id: 2, name: "b" }; + const original = { items: [item0, item1] }; + const itemsRefBefore = original.items; + + const result = applyPatch(original, [ + { op: "replace", path: "/items/0/name", value: "new" }, + ]); + + expect(result.items).not.toBe(itemsRefBefore); + expect(result.items[0]).not.toBe(item0); + expect(result.items[0]).toEqual({ id: 1, name: "new" }); + expect(result.items[1]).toBe(item1); + expect(item0.name).toBe("old"); // the original object must be untouched + }); + + it("gives nested-object identity to a field-level replace inside a plain nested object", () => { + const address = { city: "old" }; + const user = { name: "Ada", address }; + const original = { user }; + const userRefBefore = original.user; + + const result = applyPatch(original, [ + { op: "replace", path: "/user/address/city", value: "new" }, + ]); + + expect(result.user).not.toBe(userRefBefore); + expect(result.user.address).not.toBe(address); + expect(result.user.address).toEqual({ city: "new" }); + expect(result.user.name).toBe("Ada"); + expect(address.city).toBe("old"); // the original object must be untouched + }); + + it("does not corrupt an append insert with a stray undefined element", () => { + const original = { users: [{ __dom_id: "a" }] }; + + const result = applyPatch(original, [ + { op: "upsert", path: "/users/-", value: { __dom_id: "b" } }, + ]); + + expect(result.users).toEqual([{ __dom_id: "a" }, { __dom_id: "b" }]); + expect(result.users).toHaveLength(2); + }); + + it("still clones a single-segment target for limit operations", () => { + const original = { rows: [1, 2, 3, 4] }; + const rowsRefBefore = original.rows; + + const result = applyPatch(original, [ + { op: "limit", path: "/rows", value: 2 }, + ]); + + expect(result.rows).not.toBe(rowsRefBefore); + expect(result.rows).toEqual([1, 2]); + }); +}); diff --git a/assets/js/live_react/tests/helpers.js b/assets/js/live_react/tests/helpers.js new file mode 100644 index 00000000..d4d38720 --- /dev/null +++ b/assets/js/live_react/tests/helpers.js @@ -0,0 +1,30 @@ +import { vi } from "vitest"; + +let mockIdCounter = 0; + +export const createMockLiveViewHook = (elementAttributes = {}) => { + const id = elementAttributes.id || `mock-${++mockIdCounter}`; + const attributes = { ...elementAttributes }; + + const mockElement = { + id, + getAttribute: vi.fn((name) => + name in attributes ? attributes[name] : null, + ), + setAttribute: vi.fn((name, value) => { + attributes[name] = value; + }), + hasAttribute: vi.fn((name) => name in attributes), + hasChildNodes: vi.fn(() => false), + }; + + return { + el: mockElement, + pushEvent: vi.fn(), + pushEventTo: vi.fn(), + handleEvent: vi.fn(), + removeHandleEvent: vi.fn(), + upload: vi.fn(), + uploadTo: vi.fn(), + }; +}; diff --git a/docs/superpowers/plans/2026-07-13-props-diff-and-streams.md b/docs/superpowers/plans/2026-07-13-props-diff-and-streams.md new file mode 100644 index 00000000..b69cc065 --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-props-diff-and-streams.md @@ -0,0 +1,3272 @@ +# Props Diffing & LiveView Streams Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Port live_vue's props-diffing and LiveView-stream support to live_react, so both `data-props` diffs and `Phoenix.LiveView.stream/3,4` data travel to the client as compact JSON-Patch-style operations instead of full JSON payloads. + +**Architecture:** Server-side, `LiveReact.Encoder` normalizes structs into diffable maps, `Jsonpatch.diff/3` computes prop diffs, and hand-rolled patch generation reads `%Phoenix.LiveView.LiveStream{}` inserts/deletes directly (no diffing library needed there). Both diff kinds are serialized through `LiveReact.Patch`'s compact wire format into `data-props-diff` / `data-streams-diff` attributes. Client-side, the React hook keeps persistent `_props` / `_streams` state per component instance and applies patches **copy-on-write**: only the top-level value at each patch's first path segment gets a fresh reference, so `React.memo`'d children correctly skip re-rendering for untouched items and re-render for changed ones. + +**Tech Stack:** Elixir/Phoenix LiveView, `Jsonpatch` (new dep), Jason; plain JS/JSX client (no TypeScript in the library itself), Vitest + jsdom (new dev tooling) for client unit tests. + +## Global Constraints + +- Module names: `LiveReact.Encoder`, `LiveReact.Patch` (mirrors `LiveVue.Encoder`/`LiveVue.Patch` naming, adapted to this project). +- New Elixir dependency: `{:jsonpatch, "~> 2.3"}` in `mix.exs`. +- New per-instance attribute: `diff={false}` (mirrors the existing `ssr={false}` convention) to opt out of prop diffing; global default via `config :live_react, enable_props_diff: true` (default `true`). +- Wire attribute names match live_vue exactly: `data-props-diff`, `data-streams-diff`, `data-use-diff` (no reason to diverge — internal wire details, not public API). +- `data-props` switches from raw `Jason.encode!/1` to `LiveReact.Encoder.encode/1 |> LiveReact.Patch.encode_object/1`. This is a **breaking change**: struct props now require `LiveReact.Encoder` (via `@derive` or explicit `defimpl`) instead of just `Jason.Encoder`. Plain maps are unaffected. +- Client library files stay plain JS/JSX (`.js`/`.jsx`/`.mjs`), consistent with the existing `assets/js/live_react/` sources — no TypeScript is introduced. +- `object_hash` for diffing lists of maps/structs is hardcoded to the `:id` field, matching live_vue: `object_hash(%{id: id}), do: id`. +- Stream items get a `__dom_id` field merged in before encoding (e.g. `"users-1"`), used by the client for `$$dom_id`-based array lookups. This field name is unchanged from live_vue. + +--- + +## File Structure + +**New Elixir files:** +- `lib/live_react/patch.ex` — compact patch wire format (serialize/deserialize). +- `lib/live_react/encoder.ex` — `LiveReact.Encoder` protocol + built-in implementations. + +**Modified Elixir files:** +- `lib/live_react.ex` — stream/prop classification, props_diff + streams_diff computation, template attributes. +- `lib/live_react/test.ex` — expose `props_diff` / `streams_diff` / `use_diff`, switch `props` decoding. +- `mix.exs` — add `jsonpatch` dependency. +- `CHANGELOG.md` — document the breaking change. + +**New JS files:** +- `assets/js/live_react/compactPatch.js` — decode the compact wire format. +- `assets/js/live_react/jsonPatch.js` — apply patch operations, copy-on-write. +- `assets/js/live_react/tests/helpers.js` — mock LiveView hook context for hook tests. +- `vitest.config.js` (repo root) — test runner config. + +**Modified JS files:** +- `assets/js/live_react/hooks.js` — persistent `_props`/`_streams` state, `reconnected()` lifecycle. +- `package.json` (repo root) — add `vitest`/`jsdom` dev deps and `test` scripts. + +**New example files (manual verification):** +- `live_react_examples/lib/live_react_examples_web/live/stream_demo_live.ex` +- `live_react_examples/assets/react-components/stream-demo.jsx` + +--- + +### Task 1: `LiveReact.Patch` wire format + +**Files:** +- Create: `lib/live_react/patch.ex` +- Test: `test/live_react_patch_test.exs` + +**Interfaces:** +- Produces: `LiveReact.Patch.serialize(patches :: [map()]) :: binary()`, `LiveReact.Patch.deserialize(payload :: binary()) :: [list()]`, `LiveReact.Patch.encode_object(value :: term()) :: binary()`, `LiveReact.Patch.decode_object(value :: binary()) :: term()`. All later tasks depend on these four functions. + +- [ ] **Step 1: Write the failing tests** + +```elixir +defmodule LiveReactPatchTest do + use ExUnit.Case + + alias LiveReact.Patch + + describe "values" do + test "round-trips nil" do + patches = [%{op: "replace", path: "/value", value: nil}] + assert serialize_deserialize(patches) == patches + end + + test "round-trips booleans" do + patches = [ + %{op: "replace", path: "/enabled", value: true}, + %{op: "replace", path: "/disabled", value: false} + ] + + assert serialize_deserialize(patches) == patches + end + + test "round-trips integers" do + patches = [%{op: "replace", path: "/count", value: 6}] + assert serialize_deserialize(patches) == patches + end + + test "round-trips floats" do + patches = [%{op: "replace", path: "/price", value: 12.5}] + assert serialize_deserialize(patches) == patches + end + + test "round-trips strings" do + patches = [%{op: "replace", path: "/title", value: "Published"}] + assert serialize_deserialize(patches) == patches + end + + test "round-trips lists" do + patches = [%{op: "replace", path: "/tags", value: ["bug", "urgent"]}] + assert serialize_deserialize(patches) == patches + end + + test "round-trips maps" do + patches = [%{op: "replace", path: "/user", value: %{"id" => 3, "name" => "Charlie"}}] + assert serialize_deserialize(patches) == patches + end + + test "round-trips caret-encoded JSON edge cases" do + patches = [ + %{ + op: "replace", + path: "/meta", + value: %{"empty" => "", "caret" => "^", "tilde" => "~", "both" => "~^"} + } + ] + + assert serialize_deserialize(patches) == patches + end + end + + describe "paths" do + test "round-trips the document root path" do + patches = [%{op: "replace", path: "", value: %{"status" => "ready"}}] + assert serialize_deserialize(patches) == patches + end + + test "round-trips nested paths" do + patches = [%{op: "replace", path: "/profile/name", value: "Ada"}] + assert serialize_deserialize(patches) == patches + end + + test "round-trips array index paths" do + patches = [%{op: "replace", path: "/items/0/name", value: "Keyboard"}] + assert serialize_deserialize(patches) == patches + end + + test "round-trips append marker paths" do + patches = [%{op: "add", path: "/items/-", value: "new"}] + assert serialize_deserialize(patches) == patches + end + + test "round-trips JSON pointer escapes in path segments" do + patches = [%{op: "replace", path: "/settings/a~1b~0c", value: "value"}] + assert serialize_deserialize(patches) == patches + end + + test "round-trips UTF-8 paths and values using JavaScript string lengths" do + patches = [ + %{op: "replace", path: "/profile/na.me", value: "zażółć"}, + %{op: "replace", path: "/emoji", value: "🚀"} + ] + + assert Patch.serialize(patches) == "r14:/profile/na.mes6:zażółćr6:/emojis2:🚀" + assert serialize_deserialize(patches) == patches + end + end + + describe "operations" do + test "round-trips an empty patch list" do + assert serialize_deserialize([]) == [] + end + + test "round-trips remove operations without a value" do + patches = [%{op: "remove", path: "/items/0"}] + assert serialize_deserialize(patches) == patches + end + + test "omits nonce test operations when deserializing" do + patches = [ + %{op: "test", path: "", value: 123}, + %{op: "replace", path: "/count", value: 6} + ] + + assert serialize_deserialize(patches) == [%{op: "replace", path: "/count", value: 6}] + end + + test "round-trips stream upsert and limit operations" do + patches = [ + %{op: "upsert", path: "/users/-", value: %{"id" => 4, "name" => "Margaret Hamilton"}}, + %{op: "limit", path: "/users", value: 10} + ] + + assert serialize_deserialize(patches) == patches + end + end + + describe "encode_object/decode_object" do + test "round-trips a plain map" do + value = %{"name" => "Ada", "count" => 3} + assert value |> Patch.encode_object() |> Patch.decode_object() == value + end + + test "escapes carets and tildes reversibly" do + value = %{"weird" => "~^both^~"} + assert value |> Patch.encode_object() |> Patch.decode_object() == value + end + end + + defp serialize_deserialize(patches) do + patches + |> Patch.serialize() + |> Patch.deserialize() + |> Enum.map(&patch_from_wire/1) + end + + defp patch_from_wire([op, path]), do: %{op: op, path: path} + defp patch_from_wire([op, path, value]), do: %{op: op, path: path, value: value} +end +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `mix test test/live_react_patch_test.exs` +Expected: FAIL — `LiveReact.Patch` module not defined. + +- [ ] **Step 3: Implement `LiveReact.Patch`** + +```elixir +defmodule LiveReact.Patch do + @moduledoc """ + Encodes LiveReact patch operations into the compact wire format used by + `data-props-diff` and `data-streams-diff`. + + The payload is a concatenated sequence of operations. Dynamic text fields are + JavaScript-string-length-prefixed, so paths and values can contain delimiters + without extra escaping. + + Operation codes: + + | Code | Operation | + | --- | --- | + | `a` | `add` | + | `d` | `remove` | + | `r` | `replace` | + | `u` | `upsert` | + | `l` | `limit` | + | `n` | nonce marker, ignored while decoding | + + Normal operations use: + + ```text + : + ``` + + `remove` omits ``. The nonce marker uses `n` and exists only + to force LiveView to send a changed attribute. + + Value tags: + + | Tag | Value | + | --- | --- | + | `z` | `nil` | + | `b0`, `b1` | booleans | + | `n:` | number | + | `s:` | string | + | `J:` | maps, lists, and complex values | + + Paths are transported as JSON Pointer strings unchanged. + """ + + @doc """ + Serializes patch maps into a compact binary payload. + + Expected patch shapes are `%{op: op, path: path, value: value}`, + `%{op: "remove", path: path}`, and `%{op: "test", path: "", value: nonce}`. + The nonce test operation is encoded as a marker and is not returned by + `deserialize/1`. + """ + def serialize(patches) do + :erlang.iolist_to_binary(for patch <- patches, do: serialize_op(patch)) + end + + @doc """ + Encodes a JSON value for safe, compact HTML attribute transport. + + The value is encoded with Jason's default JSON escaping, then JSON quote + characters are replaced with `^`. Literal `~` and `^` characters are escaped + as `~~` and `~^`, so the transform is reversible by `decode_object/1`. + """ + def encode_object(value) do + value + |> Jason.encode!() + |> String.replace("~", "~~") + |> String.replace("^", "~^") + |> String.replace("\"", "^") + end + + @doc false + def decode_object(value) when is_binary(value) do + value + |> String.replace(~r/~~|~\^|\^/, fn + "~~" -> "~" + "~^" -> "^" + "^" -> "\"" + end) + |> Jason.decode!() + end + + @doc """ + Deserializes a compact patch payload into list-shaped operations. + + Returns `[]` for an empty payload. Decoded operations are shaped as + `[op, path]` for `remove` and `[op, path, value]` for all value-bearing + operations. Nonce markers are skipped. + """ + def deserialize(""), do: [] + + def deserialize(payload) when is_binary(payload) do + payload + |> parse_ops([]) + |> Enum.reverse() + end + + defp serialize_op(%{op: "test", path: "", value: nonce}), do: ["n", to_string(nonce)] + + defp serialize_op(%{op: op, path: path, value: value}) do + path = encode_path(path) + [op_code(op), Integer.to_string(js_string_length(path)), ?:, path, encode_value(value)] + end + + defp serialize_op(%{op: op, path: path}) do + path = encode_path(path) + [op_code(op), Integer.to_string(js_string_length(path)), ?:, path] + end + + defp encode_path(path), do: path + + defp encode_value(nil), do: "z" + defp encode_value(true), do: "b1" + defp encode_value(false), do: "b0" + + defp encode_value(value) when is_number(value) do + encoded = to_string(value) + ["n", Integer.to_string(js_string_length(encoded)), ?:, encoded] + end + + defp encode_value(value) when is_binary(value), do: ["s", Integer.to_string(js_string_length(value)), ?:, value] + + defp encode_value(value) do + encoded = encode_object(value) + ["J", Integer.to_string(js_string_length(encoded)), ?:, encoded] + end + + defp parse_ops("", acc), do: acc + + defp parse_ops("n" <> rest, acc) do + {_nonce, rest} = take_digits(rest) + parse_ops(rest, acc) + end + + defp parse_ops(<>, acc) do + {path_length, rest} = take_length(rest) + {path, rest} = take_js_string(rest, path_length) + op = op_from_code(code) + parse_op(op, path, rest, acc) + end + + defp parse_op("remove", path, rest, acc), do: parse_ops(rest, [["remove", path] | acc]) + + defp parse_op(op, path, rest, acc) do + {value, rest} = parse_value(rest) + parse_ops(rest, [[op, path, value] | acc]) + end + + defp parse_value("z" <> rest), do: {nil, rest} + defp parse_value("b1" <> rest), do: {true, rest} + defp parse_value("b0" <> rest), do: {false, rest} + + defp parse_value(<>) when tag in ["n", "s", "J"] do + {length, rest} = take_length(rest) + {encoded, rest} = take_js_string(rest, length) + + value = + case tag do + "n" -> parse_number(encoded) + "s" -> encoded + "J" -> decode_object(encoded) + end + + {value, rest} + end + + defp parse_number(value) do + case Integer.parse(value) do + {integer, ""} -> + integer + + _ -> + {float, ""} = Float.parse(value) + float + end + end + + defp take_length(payload) do + {digits, ":" <> rest} = take_digits(payload) + {String.to_integer(digits), rest} + end + + defp take_digits(payload), do: take_digits(payload, "") + + defp take_digits(<>, acc) when char in ?0..?9 do + take_digits(rest, <>) + end + + defp take_digits(rest, acc), do: {acc, rest} + + defp take_js_string(payload, length), do: take_js_string(payload, payload, length, 0) + + defp take_js_string(original, _rest, 0, bytes) do + <> = original + {value, rest} + end + + defp take_js_string(original, <>, remaining, bytes) do + units = js_code_units(codepoint) + if units > remaining, do: raise(ArgumentError, "Invalid LiveReact patch length prefix") + take_js_string(original, rest, remaining - units, bytes + utf8_byte_size(codepoint)) + end + + defp js_string_length(value), do: js_string_length(value, 0) + defp js_string_length(<<>>, acc), do: acc + + defp js_string_length(<>, acc), + do: js_string_length(rest, acc + js_code_units(codepoint)) + + defp js_code_units(codepoint) when codepoint > 0xFFFF, do: 2 + defp js_code_units(_codepoint), do: 1 + + defp utf8_byte_size(codepoint) when codepoint <= 0x7F, do: 1 + defp utf8_byte_size(codepoint) when codepoint <= 0x7FF, do: 2 + defp utf8_byte_size(codepoint) when codepoint <= 0xFFFF, do: 3 + defp utf8_byte_size(_codepoint), do: 4 + + defp op_code("add"), do: "a" + defp op_code("remove"), do: "d" + defp op_code("replace"), do: "r" + defp op_code("upsert"), do: "u" + defp op_code("limit"), do: "l" + + defp op_from_code("a"), do: "add" + defp op_from_code("d"), do: "remove" + defp op_from_code("r"), do: "replace" + defp op_from_code("u"), do: "upsert" + defp op_from_code("l"), do: "limit" +end +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `mix test test/live_react_patch_test.exs` +Expected: PASS (all tests green). + +- [ ] **Step 5: Commit** + +```bash +git add lib/live_react/patch.ex test/live_react_patch_test.exs +git commit -m "feat: add LiveReact.Patch compact wire format" +``` + +--- + +### Task 2: `LiveReact.Encoder` protocol core + +**Files:** +- Create: `lib/live_react/encoder.ex` +- Test: `test/live_react_encoder_test.exs` + +**Interfaces:** +- Consumes: nothing from Task 1. +- Produces: `LiveReact.Encoder.encode(value :: term(), opts :: keyword()) :: term()`. Tasks 3, 5, and 6 call this. + +- [ ] **Step 1: Write the failing tests** + +```elixir +defmodule LiveReact.EncoderTest do + use ExUnit.Case + + alias LiveReact.Encoder + + describe "primitive types" do + test "encodes integers, floats, strings, booleans, nil, atoms" do + assert Encoder.encode(42) == 42 + assert Encoder.encode(3.14) == 3.14 + assert Encoder.encode("hello") == "hello" + assert Encoder.encode(true) == true + assert Encoder.encode(false) == false + assert Encoder.encode(nil) == nil + assert Encoder.encode(:hello) == :hello + end + end + + describe "complex types" do + test "encodes lists recursively" do + assert Encoder.encode([1, [2, 3], 4]) == [1, [2, 3], 4] + assert Encoder.encode([]) == [] + end + + test "encodes maps recursively" do + nested = %{user: %{name: "John", age: 30}, items: [1, 2, 3]} + assert Encoder.encode(nested) == nested + end + end + + defmodule TestUser do + @moduledoc false + @derive Encoder + defstruct [:name, :age, :email] + end + + defmodule TestAccount do + @moduledoc false + @derive Encoder + defstruct [:user, :balance] + end + + defmodule DerivedUserOnly do + @moduledoc false + @derive {Encoder, only: [:name, :age]} + defstruct [:name, :age, :email, :password] + end + + defmodule DerivedUserExcept do + @moduledoc false + @derive {Encoder, except: [:password]} + defstruct [:name, :age, :email, :password] + end + + defmodule NotDerivedUser do + @moduledoc false + defstruct [:name, :age, :email] + end + + describe "structs" do + test "encodes structs to maps without __struct__" do + user = %TestUser{name: "John", age: 30, email: "john@example.com"} + encoded = Encoder.encode(user) + + assert encoded == %{name: "John", age: 30, email: "john@example.com"} + refute Map.has_key?(encoded, :__struct__) + end + + test "encodes nested structs" do + account = %TestAccount{user: %TestUser{name: "John", age: 30, email: "j@x.com"}, balance: 1000} + + assert Encoder.encode(account) == %{ + user: %{name: "John", age: 30, email: "j@x.com"}, + balance: 1000 + } + end + + test "encodes structs in lists and maps" do + users = [%TestUser{name: "John", age: 30, email: "j@x.com"}] + assert Encoder.encode(users) == [%{name: "John", age: 30, email: "j@x.com"}] + + assert Encoder.encode(%{admin: %TestUser{name: "Jane", age: 25, email: "ja@x.com"}}) == + %{admin: %{name: "Jane", age: 25, email: "ja@x.com"}} + end + end + + describe "deriving functionality" do + test "derives encoder with only specified fields" do + user = %DerivedUserOnly{name: "John", age: 30, email: "j@x.com", password: "secret"} + assert Encoder.encode(user) == %{name: "John", age: 30} + end + + test "derives encoder excluding specified fields" do + user = %DerivedUserExcept{name: "John", age: 30, email: "j@x.com", password: "secret"} + assert Encoder.encode(user) == %{name: "John", age: 30, email: "j@x.com"} + end + + test "non-derived structs raise protocol error" do + struct = %NotDerivedUser{name: "John", age: 30, email: "j@x.com"} + + assert_raise Protocol.UndefinedError, ~r/LiveReact.Encoder protocol must always be explicitly implemented/, fn -> + Encoder.encode(struct) + end + end + end + + describe "date and time types" do + test "encodes date and time types as ISO8601 strings" do + date = ~D[2023-01-01] + naive_datetime = ~N[2023-01-01 12:00:00] + datetime = DateTime.from_naive!(naive_datetime, "Etc/UTC") + + assert Encoder.encode(date) == Date.to_iso8601(date) + assert Encoder.encode(naive_datetime) == NaiveDateTime.to_iso8601(naive_datetime) + assert Encoder.encode(datetime) == DateTime.to_iso8601(datetime) + end + end +end +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `mix test test/live_react_encoder_test.exs` +Expected: FAIL — `LiveReact.Encoder` module not defined. + +- [ ] **Step 3: Implement the protocol core** + +```elixir +defprotocol LiveReact.Encoder do + @moduledoc """ + Protocol for encoding values to JSON for LiveReact. + + This protocol is used to safely transform structs into plain maps before + calculating JSON patches. It ensures that struct fields are explicitly + exposed and prevents accidental exposure of sensitive data. + + It's very similar to Jason.Encoder, but it's converting structs to maps instead of strings. + + ## Deriving + + The protocol allows leveraging Elixir's `@derive` feature to simplify protocol + implementation in trivial cases. Accepted options are: + + * `:only` - encodes only values of specified keys. + * `:except` - encodes all struct fields except specified keys. + + By default all keys except the `:__struct__` key are encoded. + + ## Example + + defmodule User do + @derive LiveReact.Encoder + defstruct [:name, :email, :password] + end + + If we called `@derive {LiveReact.Encoder, only: [:name, :email]}`, only the + specified fields would be encoded. If we called + `@derive {LiveReact.Encoder, except: [:password]}`, all fields except the + specified ones would be encoded. + + ## Deriving outside of the module + + Protocol.derive(LiveReact.Encoder, User, only: [...]) + + ## Custom implementations + + defimpl LiveReact.Encoder, for: User do + def encode(struct, opts) do + struct + |> Map.take([:first, :second]) + |> LiveReact.Encoder.encode(opts) + end + end + """ + + @type t :: term + @type opts :: Keyword.t() + @fallback_to_any true + + @doc """ + Encodes a value to one of the primitive types. + """ + @spec encode(t, opts) :: any() + def encode(value, opts \\ []) +end + +defimpl LiveReact.Encoder, for: Integer do + def encode(value, _opts), do: value +end + +defimpl LiveReact.Encoder, for: Float do + def encode(value, _opts), do: value +end + +defimpl LiveReact.Encoder, for: BitString do + def encode(value, _opts), do: value +end + +defimpl LiveReact.Encoder, for: Atom do + def encode(atom, _opts), do: atom +end + +defimpl LiveReact.Encoder, for: List do + def encode(list, opts) do + Enum.map(list, &LiveReact.Encoder.encode(&1, opts)) + end +end + +defimpl LiveReact.Encoder, for: Map do + def encode(map, opts) do + Map.new(map, fn {key, value} -> + {key, LiveReact.Encoder.encode(value, opts)} + end) + end +end + +defimpl LiveReact.Encoder, for: [Date, Time, NaiveDateTime, DateTime] do + def encode(value, _opts) do + @for.to_iso8601(value) + end +end + +defimpl LiveReact.Encoder, for: Any do + defmacro __deriving__(module, struct, opts) do + fields = fields_to_encode(struct, opts) + + quote do + defimpl LiveReact.Encoder, for: unquote(module) do + def encode(struct, opts) do + struct + |> Map.take(unquote(fields)) + |> LiveReact.Encoder.encode(opts) + end + end + end + end + + def encode(%{__struct__: module} = struct, _opts) do + raise Protocol.UndefinedError, + protocol: @protocol, + value: struct, + description: """ + LiveReact.Encoder protocol must always be explicitly implemented. + + It's used to encode structs to JSON for LiveReact. It's very similar to Jason.Encoder, + but it's converting structs to maps so LiveReact can diff them correctly. + + If you own the struct, you can derive the implementation specifying \ + which fields should be encoded: + + defmodule #{inspect(module)} do + @derive {LiveReact.Encoder, only: [...]} + defstruct ... + end + + If you don't own the struct you want to encode, \ + you may use Protocol.derive/3 placed outside of any module: + + Protocol.derive(LiveReact.Encoder, #{inspect(module)}, only: [...]) + Protocol.derive(LiveReact.Encoder, #{inspect(module)}) + + Nothing prevents you from defining your own implementation for the struct: + + defimpl LiveReact.Encoder, for: #{inspect(module)} do + def encode(struct, opts) do + struct + |> Map.take([:first, :second]) + |> LiveReact.Encoder.encode(opts) + end + end + """ + end + + def encode(value, _opts), do: value + + defp fields_to_encode(struct, opts) do + fields = Map.keys(struct) + + cond do + only = Keyword.get(opts, :only) -> + case only -- fields do + [] -> + only + + error_keys -> + raise ArgumentError, + ":only specified keys (#{inspect(error_keys)}) that are not defined in defstruct: " <> + "#{inspect(fields -- [:__struct__])}" + end + + except = Keyword.get(opts, :except) -> + case except -- fields do + [] -> + fields -- [:__struct__ | except] + + error_keys -> + raise ArgumentError, + ":except specified keys (#{inspect(error_keys)}) that are not defined in defstruct: " <> + "#{inspect(fields -- [:__struct__])}" + end + + true -> + fields -- [:__struct__] + end + end +end +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `mix test test/live_react_encoder_test.exs` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add lib/live_react/encoder.ex test/live_react_encoder_test.exs +git commit -m "feat: add LiveReact.Encoder protocol core" +``` + +--- + +### Task 3: `LiveReact.Encoder` LiveView struct implementations + +**Files:** +- Modify: `lib/live_react/encoder.ex` +- Test: `test/live_react_encoder_liveview_test.exs` + +**Interfaces:** +- Consumes: `LiveReact.Encoder.encode/2` (Task 2). +- Produces: `LiveReact.Encoder` implementations for `Phoenix.LiveView.AsyncResult`, `Phoenix.LiveView.UploadConfig`, `Phoenix.LiveView.UploadEntry`. No new public functions — later tasks rely on these structs being encodable when passed as props. + +- [ ] **Step 1: Write the failing tests** + +```elixir +defmodule LiveReact.Encoder.LiveViewTest do + use ExUnit.Case + + alias LiveReact.Encoder + alias Phoenix.LiveView.AsyncResult + alias Phoenix.LiveView.UploadConfig + alias Phoenix.LiveView.UploadEntry + + describe "AsyncResult" do + test "encodes loading state" do + result = AsyncResult.loading() + encoded = Encoder.encode(result) + + assert encoded.ok == false + assert encoded.loading == true + assert encoded.result == nil + end + + test "encodes successful state" do + result = AsyncResult.loading() |> AsyncResult.ok("value") + encoded = Encoder.encode(result) + + assert encoded.ok == true + assert encoded.result == "value" + end + + test "encodes failed state" do + result = AsyncResult.loading() |> AsyncResult.failed({:error, "boom"}) + encoded = Encoder.encode(result) + + assert encoded.ok == false + assert encoded.failed == "boom" + end + end + + describe "UploadEntry / UploadConfig" do + test "encodes an empty UploadConfig" do + config = UploadConfig.build_upload_config(%{accept: :any, max_entries: 1}, :avatar, []) + encoded = Encoder.encode(config) + + assert encoded.ref + assert encoded.name == :avatar + assert encoded.entries == [] + assert encoded.errors == [] + end + end +end +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `mix test test/live_react_encoder_liveview_test.exs` +Expected: FAIL — `Protocol.UndefinedError` for `AsyncResult`/`UploadConfig`. + +- [ ] **Step 3: Add the LiveView-specific implementations** + +Append to `lib/live_react/encoder.ex` (after the `Any` impl block from Task 2): + +```elixir +defimpl LiveReact.Encoder, for: Phoenix.LiveView.AsyncResult do + def encode(%Phoenix.LiveView.AsyncResult{} = struct, opts) do + LiveReact.Encoder.encode( + %{ + ok: struct.ok?, + loading: struct.loading, + failed: encode_failed(struct.failed), + result: struct.result + }, + opts + ) + end + + defp encode_failed({:error, reason}), do: reason + defp encode_failed({:exit, reason}), do: reason + defp encode_failed(other), do: other +end + +defimpl LiveReact.Encoder, for: Phoenix.LiveView.UploadConfig do + def encode(%Phoenix.LiveView.UploadConfig{} = struct, opts) do + errors = + Enum.map(struct.errors, fn {key, value} -> + %{ref: key, error: LiveReact.Encoder.encode(value, opts)} + end) + + entries = + Enum.map(struct.entries, fn entry -> + encoded = LiveReact.Encoder.encode(entry, opts) + entry_errors = errors |> Enum.filter(&(&1.ref == entry.ref)) |> Enum.map(& &1.error) + Map.put(encoded, :errors, entry_errors) + end) + + LiveReact.Encoder.encode( + %{ + ref: struct.ref, + name: struct.name, + accept: struct.accept, + max_entries: struct.max_entries, + auto_upload: struct.auto_upload?, + entries: entries, + errors: errors + }, + opts + ) + end +end + +defimpl LiveReact.Encoder, for: Phoenix.LiveView.UploadEntry do + def encode(%Phoenix.LiveView.UploadEntry{} = struct, opts) do + LiveReact.Encoder.encode( + %{ + ref: struct.ref, + client_name: struct.client_name, + client_size: struct.client_size, + client_type: struct.client_type, + progress: struct.progress, + done: struct.done?, + valid: struct.valid?, + preflighted: struct.preflighted? + }, + opts + ) + end +end +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `mix test test/live_react_encoder_liveview_test.exs` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add lib/live_react/encoder.ex test/live_react_encoder_liveview_test.exs +git commit -m "feat: encode LiveView AsyncResult/Upload structs via LiveReact.Encoder" +``` + +--- + +### Task 4: `mix.exs` dependency + stream/prop classification in `react/1` + +**Files:** +- Modify: `mix.exs` +- Modify: `lib/live_react.ex` +- Test: `test/live_react_classification_test.exs` + +**Interfaces:** +- Consumes: nothing new. +- Produces: `normalize_key/2` now classifies `%Phoenix.LiveView.LiveStream{}` values as `:streams` (distinct from `:props`), and a `:diff` special key is recognized (so it isn't treated as a prop). Task 5/6 build on this classification to extract `streams` separately from `props`. + +- [ ] **Step 1: Add the dependency** + +In `mix.exs`, add to `defp deps do`: + +```elixir +{:jsonpatch, "~> 2.3"}, +``` + +Run: `mix deps.get` +Expected: `jsonpatch` fetched successfully. + +- [ ] **Step 2: Write the failing test** + +```elixir +defmodule LiveReactClassificationTest do + use ExUnit.Case + + import Phoenix.Component + import Phoenix.LiveViewTest + + alias LiveReact.Test + alias Phoenix.LiveView.LiveStream + + def stream_component(assigns) do + ~H""" + <.react name="TestComponent" users={@users} title={@title} /> + """ + end + + test "LiveStream values are excluded from props" do + stream = LiveStream.new(:users, make_ref(), [], []) + + html = + render_component(&stream_component/1, users: stream, title: "My Page") + + react = Test.get_react(html) + + assert react.props == %{"title" => "My Page"} + end +end +``` + +- [ ] **Step 3: Run the test to verify it fails** + +Run: `mix test test/live_react_classification_test.exs` +Expected: FAIL — `react.props` still includes a (non-JSON-encodable or malformed) `users` key, since `%LiveStream{}` isn't yet classified separately and `Jsonpatch`/`Encoder` aren't wired into `data-props` yet (this will actually raise, since `Jason.encode!` cannot encode a `%LiveStream{}` struct without a `Jason.Encoder` impl — confirming the gap). + +- [ ] **Step 4: Classify `%LiveStream{}` in `lib/live_react.ex`** + +In `lib/live_react.ex`, add the alias and extend `normalize_key/2`: + +```elixir +alias Phoenix.LiveView.LiveStream +``` + +```elixir +defp normalize_key(key, _val) when key in ~w(id class ssr diff name socket __changed__ __given__)a, + do: :special + +defp normalize_key(_key, [%{__slot__: _}]), do: :slots +defp normalize_key(key, val) when is_atom(key), do: key |> to_string() |> normalize_key(val) +defp normalize_key(_key, %LiveStream{}), do: :streams +defp normalize_key(_key, _val), do: :props +``` + +(Note: `%LiveStream{}` clause must come after the `is_atom(key)` string-conversion clause and before the catch-all `:props` clause, matching the existing pattern-match order.) + +No other code changes are needed for this task: `react/1`'s existing `extract(assigns, :props)` call already filters by `normalize_key/2`, so once `%LiveStream{}` values classify as `:streams` instead of `:props`, they're automatically excluded from `data-props`. + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `mix test test/live_react_classification_test.exs` +Expected: PASS. + +- [ ] **Step 6: Run the full existing suite to check for regressions** + +Run: `mix test` +Expected: PASS (existing `live_react_test.exs` still green — stream classification is additive). + +- [ ] **Step 7: Commit** + +```bash +git add mix.exs mix.lock lib/live_react.ex test/live_react_classification_test.exs +git commit -m "feat: classify LiveStream assigns separately from props, add jsonpatch dep" +``` + +--- + +### Task 5: Props diffing in `react/1` + +**Files:** +- Modify: `lib/live_react.ex` +- Test: `test/live_react_props_diff_test.exs` + +**Interfaces:** +- Consumes: `LiveReact.Encoder.encode/1` (Task 2/3), `LiveReact.Patch.serialize/1` + `encode_object/1` (Task 1), `Jsonpatch.diff/3` (Task 4 dependency). +- Produces: `data-props`, `data-props-diff`, `data-use-diff` attributes on the rendered `
`; a `diff={false}` per-instance opt-out; `@diff_default` config key `:enable_props_diff`. `LiveReact.Test` (Task 7) reads these attributes. + +- [ ] **Step 1: Write the failing tests** + +```elixir +defmodule LiveReactPropsDiffTest do + use ExUnit.Case + + import Phoenix.Component + + alias LiveReact.Test + + defp render_react_assigns(assigns) do + rendered = LiveReact.react(assigns) + html = rendered |> Phoenix.HTML.html_escape() |> Phoenix.HTML.safe_to_string() + Test.get_react(html) + end + + defp assert_patches_equal(actual, expected) do + actual_sorted = actual |> decode_patch() |> Enum.sort_by(& &1["path"]) + expected_sorted = Enum.sort_by(expected, & &1["path"]) + assert actual_sorted == expected_sorted + end + + defp decode_patch(patch_list) do + patch_list + |> Enum.map(fn + [op, path] -> %{"op" => op, "path" => path} + [op, path, value] -> %{"op" => op, "path" => path, "value" => value} + end) + |> Enum.reject(&(&1["op"] == "test")) + end + + describe "props_diff functionality" do + test "initial render has empty props_diff and use_diff true" do + assigns = %{name: "John", age: 30, "socket": nil, __changed__: nil} + + react = render_react_assigns(assigns) + + assert react.props == %{"name" => "John", "age" => 30} + assert react.use_diff == true + assert_patches_equal(react.props_diff, []) + end + + test "single simple prop change creates replace operation" do + assigns = %{name: "John", age: 30, __changed__: %{}} + assigns = assign(assigns, :name, "Jane") + + react = render_react_assigns(assigns) + + assert_patches_equal(react.props_diff, [%{"op" => "replace", "path" => "/name", "value" => "Jane"}]) + end + + test "complex prop changes use Jsonpatch.diff for minimal operations" do + assigns = %{user: %{name: "John", age: 30}, __changed__: %{}} + assigns = assign(assigns, :user, %{name: "Alice", age: 25}) + + react = render_react_assigns(assigns) + + assert_patches_equal(react.props_diff, [ + %{"op" => "replace", "path" => "/user/age", "value" => 25}, + %{"op" => "replace", "path" => "/user/name", "value" => "Alice"} + ]) + end + + test "unchanged props do not appear in diff" do + assigns = %{name: "John", age: 30, __changed__: %{}} + assigns = assign(assigns, :name, "Bob") + + react = render_react_assigns(assigns) + + assert_patches_equal(react.props_diff, [%{"op" => "replace", "path" => "/name", "value" => "Bob"}]) + end + + test "lists are diffed based on id field" do + assigns = %{ + items: [%{id: 1, name: "Alice"}, %{id: 2, name: "Bob"}], + __changed__: %{} + } + + assigns = assign(assigns, :items, [%{id: 1, name: "Alice"}, %{id: 2, name: "New Bob"}]) + + react = render_react_assigns(assigns) + + assert_patches_equal(react.props_diff, [ + %{"op" => "replace", "path" => "/items/1/name", "value" => "New Bob"} + ]) + end + + test "it's possible to disable diffs per-instance" do + assigns = %{user: %{name: "John", age: 30}, diff: false, __changed__: %{}} + assigns = assign(assigns, :user, %{name: "Jane", age: 25}) + + react = render_react_assigns(assigns) + + assert react.use_diff == false + assert react.props == %{"user" => %{"name" => "Jane", "age" => 25}} + assert_patches_equal(react.props_diff, []) + end + + defmodule User do + @moduledoc false + @derive LiveReact.Encoder + defstruct [:name, :age] + end + + test "for structs uses LiveReact.Encoder to convert to map" do + assigns = %{user: %User{name: "John", age: 30}, __changed__: %{}} + assigns = assign(assigns, :user, %User{name: "Alice", age: 25}) + + react = render_react_assigns(assigns) + + assert_patches_equal(react.props_diff, [ + %{"op" => "replace", "path" => "/user/age", "value" => 25}, + %{"op" => "replace", "path" => "/user/name", "value" => "Alice"} + ]) + end + + test "struct props without LiveReact.Encoder raise a helpful error" do + defmodule Undecoded do + @moduledoc false + defstruct [:name] + end + + assigns = %{user: %Undecoded{name: "John"}, __changed__: nil} + + assert_raise Protocol.UndefinedError, ~r/LiveReact.Encoder protocol must always be explicitly implemented/, fn -> + render_react_assigns(assigns) + end + end + end +end +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `mix test test/live_react_props_diff_test.exs` +Expected: FAIL — `react.props_diff`/`react.use_diff` are `nil` (attributes don't exist yet), and `render_react_assigns/1` errors since `data-props` still uses raw `Jason.encode!`. + +- [ ] **Step 3: Implement props diffing in `lib/live_react.ex`** + +Replace the body of `lib/live_react.ex` with: + +```elixir +defmodule LiveReact do + @moduledoc """ + See READ.md for installation instructions and examples. + """ + + use Phoenix.Component + import Phoenix.HTML + + alias LiveReact.Encoder + alias LiveReact.Patch + alias LiveReact.SSR + alias LiveReact.Slots + alias Phoenix.LiveView + alias Phoenix.LiveView.LiveStream + + require Logger + + @ssr_default Application.compile_env(:live_react, :ssr, true) + @diff_default Application.compile_env(:live_react, :enable_props_diff, true) + + @doc """ + Render a React component. + """ + def react(assigns) do + init = assigns.__changed__ == nil + dead = assigns[:socket] == nil or not LiveView.connected?(assigns[:socket]) + use_diff = Map.get(assigns, :diff, @diff_default) + use_streams_diff = Enum.any?(assigns, fn {_k, v} -> match?(%LiveStream{}, v) end) + render_ssr? = init and dead and Map.get(assigns, :ssr, @ssr_default) + + base_assigns = + if use_diff do + Enum.filter(assigns, fn {k, _v} -> key_changed(assigns, k) end) + else + assigns + end + + {props, _} = extract(base_assigns, :props) + {streams, _} = extract(base_assigns, :streams) + {slots, slots_changed?} = extract(assigns, :slots) + component_name = Map.get(assigns, :name) + + props_diff = if use_diff, do: calculate_props_diff(props, assigns), else: [] + streams_diff = if use_streams_diff, do: calculate_streams_diff(streams, init or dead), else: [] + + assigns = + assigns + |> Map.put_new(:class, nil) + |> Map.put(:__component_name, component_name) + |> Map.put(:props, props) + |> Map.put(:props_diff, Patch.serialize(props_diff)) + |> Map.put(:streams_diff, Patch.serialize(streams_diff)) + |> Map.put(:use_diff, use_diff) + |> Map.put(:slots, if(slots_changed?, do: Slots.rendered_slot_map(slots), else: %{})) + + assigns = + Map.put(assigns, :ssr_render, if(render_ssr?, do: ssr_render(assigns), else: nil)) + + computed_changed = + %{ + props: init or dead or not use_diff, + slots: slots_changed?, + ssr_render: render_ssr?, + props_diff: not init and not dead and use_diff, + streams_diff: use_streams_diff + } + + assigns = + update_in(assigns.__changed__, fn + nil -> nil + changed -> for {k, true} <- computed_changed, into: changed, do: {k, true} + end) + + # It's important to not add extra `\n` in the inner div or it will break hydration + ~H""" +
Slots.base_encode_64 |> json}"} + data-ssr={is_map(@ssr_render)} + data-use-diff={@use_diff |> to_string()} + phx-update="ignore" + phx-hook="ReactHook" + class={@class} + ><%= raw(@ssr_render[:html]) %>
+ """ + end + + # Calculates minimal JSON Patch operations for changed props only. + # Uses Phoenix LiveView's __changed__ tracking to identify what props have changed. + defp calculate_props_diff(props, %{__changed__: changed}) do + props + |> Enum.flat_map(fn {k, new_value} -> + case changed[k] do + nil -> + [] + + true -> + [%{op: "replace", path: "/#{k}", value: Encoder.encode(new_value)}] + + old_value -> + Jsonpatch.diff(old_value, new_value, + ancestor_path: "/#{k}", + prepare_map: fn + struct when is_struct(struct) -> Encoder.encode(struct) + rest -> rest + end, + object_hash: &object_hash/1 + ) + end + end) + |> then(fn diff -> [%{op: "test", path: "", value: :rand.uniform(10_000_000)} | diff] end) + end + + defp object_hash(%{id: id}), do: id + defp object_hash(_), do: nil + + # streams_diff implementation added in Task 6 + defp calculate_streams_diff(_streams, _initial), do: [] + + defp extract(assigns, type) do + Enum.reduce(assigns, {%{}, false}, fn {key, value}, {acc, changed} -> + case normalize_key(key, value) do + ^type -> {Map.put(acc, key, value), changed || key_changed(assigns, key)} + _ -> {acc, changed} + end + end) + end + + defp normalize_key(key, _val) when key in ~w(id class ssr diff name socket __changed__ __given__)a, + do: :special + + defp normalize_key(_key, [%{__slot__: _}]), do: :slots + defp normalize_key(key, val) when is_atom(key), do: key |> to_string() |> normalize_key(val) + defp normalize_key(_key, %LiveStream{}), do: :streams + defp normalize_key(_key, _val), do: :props + + defp key_changed(%{__changed__: nil}, _key), do: true + defp key_changed(%{__changed__: changed}, key), do: changed[key] != nil + + defp ssr_render(assigns) do + try do + name = Map.get(assigns, :name) + + SSR.render(name, Encoder.encode(assigns.props), assigns.slots) + rescue + SSR.NotConfigured -> + nil + end + end + + defp json(data), do: Jason.encode!(data, escape: :html_safe) + + defp id(name) do + # a small trick to avoid collisions of IDs but keep them consistent across dead and live render + # id(name) is called only once during the whole LiveView lifecycle because it's not using any assigns + number = Process.get(:live_react_counter, 1) + Process.put(:live_react_counter, number + 1) + "#{name}-#{number}" + end +end +``` + +(`calculate_streams_diff/2` is a stub returning `[]` for now — Task 6 replaces it with the real implementation. `extract/2`, `normalize_key/2`, and `key_changed/2` here supersede the versions from Task 4.) + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `mix test test/live_react_props_diff_test.exs` +Expected: PASS. + +- [ ] **Step 5: Run the full suite for regressions** + +Run: `mix test` +Expected: PASS. (`live_react_test.exs`'s existing prop assertions still hold since non-diffed/initial-render props are unaffected in shape, only encoding changed from `Jason.encode!` to `Encoder.encode |> Patch.encode_object` — both produce the same JSON content for plain maps.) + +- [ ] **Step 6: Commit** + +```bash +git add lib/live_react.ex test/live_react_props_diff_test.exs +git commit -m "feat: compute props_diff via Jsonpatch and LiveReact.Encoder" +``` + +--- + +### Task 6: Stream diffing in `react/1` + +**Files:** +- Modify: `lib/live_react.ex` +- Test: `test/live_react_streams_diff_test.exs` + +**Interfaces:** +- Consumes: `LiveReact.Encoder.encode/1` (Task 2), `LiveReact.Patch.serialize/1` (Task 1). +- Produces: real `calculate_streams_diff/2` (replacing Task 5's stub), populating `data-streams-diff` for any `%Phoenix.LiveView.LiveStream{}` assign. + +- [ ] **Step 1: Write the failing tests** + +```elixir +defmodule LiveReactStreamsDiffTest do + use ExUnit.Case + + alias LiveReact.Test + alias Phoenix.LiveView.LiveStream + + defp render_react_assigns(assigns) do + rendered = LiveReact.react(assigns) + html = rendered |> Phoenix.HTML.html_escape() |> Phoenix.HTML.safe_to_string() + Test.get_react(html) + end + + defp assert_patches_equal(actual, expected) do + actual_sorted = actual |> decode_patch() |> Enum.sort_by(&{&1["path"], &1["op"]}) + expected_sorted = Enum.sort_by(expected, &{&1["path"], &1["op"]}) + assert actual_sorted == expected_sorted + end + + defp decode_patch(patch_list) do + patch_list + |> Enum.map(fn + [op, path] -> %{"op" => op, "path" => path} + [op, path, value] -> %{"op" => op, "path" => path, "value" => value} + end) + |> Enum.reject(&(&1["op"] == "test")) + end + + defmodule StreamUser do + @moduledoc false + @derive LiveReact.Encoder + defstruct [:id, :name, :age] + end + + describe "LiveStream diff functionality" do + test "initial render with LiveStream has stream diff in streams_diff" do + users = [%StreamUser{id: 1, name: "Alice", age: 30}, %StreamUser{id: 2, name: "Bob", age: 25}] + stream = LiveStream.new(:users, make_ref(), users, []) + + react = render_react_assigns(%{users: stream, __changed__: nil}) + + expected_patches = [ + %{"op" => "replace", "path" => "/users", "value" => []}, + %{"op" => "upsert", "path" => "/users/-", "value" => %{"__dom_id" => "users-1", "age" => 30, "id" => 1, "name" => "Alice"}}, + %{"op" => "upsert", "path" => "/users/-", "value" => %{"__dom_id" => "users-2", "age" => 25, "id" => 2, "name" => "Bob"}} + ] + + assert react.props == %{} + assert_patches_equal(react.streams_diff, expected_patches) + end + + test "inserting item to LiveStream creates upsert operation" do + new_user = %StreamUser{id: 3, name: "Charlie", age: 28} + stream = LiveStream.new(:users, make_ref(), [], []) + stream = LiveStream.insert_item(stream, new_user, -1, nil, false) + + react = + render_react_assigns(%{ + users: stream, + __changed__: %{users: LiveStream.new(:users, make_ref(), [], [])} + }) + + assert_patches_equal(react.streams_diff, [ + %{"op" => "upsert", "path" => "/users/-", "value" => %{"id" => 3, "name" => "Charlie", "age" => 28, "__dom_id" => "users-3"}} + ]) + end + + test "deleting item from LiveStream creates remove operation" do + user_to_delete = %StreamUser{id: 2, name: "Bob", age: 25} + stream = LiveStream.new(:users, make_ref(), [], []) + stream = LiveStream.delete_item(stream, user_to_delete) + + react = + render_react_assigns(%{ + users: stream, + __changed__: %{users: LiveStream.new(:users, make_ref(), [], [])} + }) + + assert_patches_equal(react.streams_diff, [%{"op" => "remove", "path" => "/users/$$users-2"}]) + end + + test "resetting LiveStream creates replace operation" do + stream = LiveStream.new(:users, make_ref(), [], []) + stream = LiveStream.reset(stream) + + react = + render_react_assigns(%{ + users: stream, + __changed__: %{users: LiveStream.new(:users, make_ref(), [], [])} + }) + + assert_patches_equal(react.streams_diff, [%{"op" => "replace", "path" => "/users", "value" => []}]) + end + + test "stream with limit adds limit operation" do + stream = LiveStream.new(:users, make_ref(), [], []) + stream = LiveStream.insert_item(stream, %StreamUser{id: 1, name: "User", age: 1}, -1, 5, false) + + react = + render_react_assigns(%{ + users: stream, + __changed__: %{users: LiveStream.new(:users, make_ref(), [], [])} + }) + + decoded = decode_patch(react.streams_diff) + limit_op = Enum.find(decoded, &(&1["op"] == "limit")) + + assert limit_op == %{"op" => "limit", "path" => "/users", "value" => 5} + end + + test "stream insert with update_only flag creates replace operation" do + stream = LiveStream.new(:users, make_ref(), [], []) + stream = LiveStream.insert_item(stream, %StreamUser{id: 1, name: "Updated", age: 1}, -1, nil, true) + + react = + render_react_assigns(%{ + users: stream, + __changed__: %{users: LiveStream.new(:users, make_ref(), [], [])} + }) + + decoded = decode_patch(react.streams_diff) + replace_op = Enum.find(decoded, &(&1["op"] == "replace" && String.contains?(&1["path"], "$$"))) + + assert replace_op["value"]["name"] == "Updated" + end + + test "LiveStream assigns do not appear in props" do + stream = LiveStream.new(:users, make_ref(), [], []) + + react = render_react_assigns(%{users: stream, title: "Page", __changed__: nil}) + + assert react.props == %{"title" => "Page"} + end + end +end +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `mix test test/live_react_streams_diff_test.exs` +Expected: FAIL — `calculate_streams_diff/2` is still the Task 5 stub returning `[]`. + +- [ ] **Step 3: Implement real stream diffing** + +In `lib/live_react.ex`, replace the stub: + +```elixir +defp calculate_streams_diff(_streams, _initial), do: [] +``` + +with: + +```elixir +# Generates JSON patch operations for LiveStream changes. +# Handles insertions and deletions for Phoenix LiveView streams. +defp calculate_streams_diff(streams, initial) + +defp calculate_streams_diff(streams, true) do + # for initial render, we want to reset all streams, and then apply the diffs + init = Enum.map(streams, fn {k, _} -> %{op: "replace", path: "/#{k}", value: []} end) + diffs = Enum.flat_map(streams, fn {k, stream} -> generate_stream_patches(k, stream) end) + init ++ diffs +end + +defp calculate_streams_diff(streams, false) do + streams + |> Enum.flat_map(fn {k, stream} -> generate_stream_patches(k, stream) end) + |> then(fn diff -> [%{op: "test", path: "", value: :rand.uniform(10_000_000)} | diff] end) +end + +# Generates JSON patch operations for a single LiveStream's changes. +defp generate_stream_patches(stream_name, %LiveStream{} = stream) do + patches = [] + + patches = + if stream.reset?, + do: [%{op: "replace", path: "/#{stream_name}", value: []} | patches], + else: patches + + patches = + Enum.reduce(stream.deletes, patches, fn dom_id, patches -> + [%{op: "remove", path: "/#{stream_name}/$$#{dom_id}"} | patches] + end) + + # Reversed - inserts at -1 should be correctly ordered, inserts at 0 should be reversed + # see https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#stream/4 :at option + stream.inserts + |> Enum.reverse() + |> Enum.reduce(patches, fn {dom_id, at, item, limit, update_only}, patches -> + item = Map.put(Encoder.encode(item), :__dom_id, dom_id) + + patches = + if update_only, + do: [%{op: "replace", path: "/#{stream_name}/$$#{dom_id}", value: item} | patches], + else: [%{op: "upsert", path: "/#{stream_name}/#{if at == -1, do: "-", else: at}", value: item} | patches] + + if limit, + do: [%{op: "limit", path: "/#{stream_name}", value: limit} | patches], + else: patches + end) + |> Enum.reverse() +end +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `mix test test/live_react_streams_diff_test.exs` +Expected: PASS. + +- [ ] **Step 5: Run the full suite for regressions** + +Run: `mix test` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add lib/live_react.ex test/live_react_streams_diff_test.exs +git commit -m "feat: compute streams_diff from Phoenix.LiveView.LiveStream assigns" +``` + +--- + +### Task 7: `LiveReact.Test` updates + +**Files:** +- Modify: `lib/live_react/test.ex` +- Test: `test/live_react_test_helper_test.exs` + +**Interfaces:** +- Consumes: `LiveReact.Patch.decode_object/1` + `deserialize/1` (Task 1). +- Produces: `LiveReact.Test.get_react/2` now returns `:props_diff`, `:streams_diff`, `:use_diff` keys in addition to the existing ones. Later manual/example verification and any future test suites rely on this shape. + +- [ ] **Step 1: Write the failing test** + +```elixir +defmodule LiveReactTestHelperTest do + use ExUnit.Case + + import Phoenix.Component + import Phoenix.LiveViewTest + + alias LiveReact.Test + + def component(assigns) do + ~H""" + <.react name="TestComponent" title="Hello" /> + """ + end + + test "get_react exposes props_diff, streams_diff, and use_diff" do + html = render_component(&component/1) + react = Test.get_react(html) + + assert react.props == %{"title" => "Hello"} + assert react.use_diff == true + assert react.props_diff == [] + assert react.streams_diff == [] + end +end +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `mix test test/live_react_test_helper_test.exs` +Expected: FAIL — `react.props_diff`/`react.streams_diff`/`react.use_diff` are `KeyError`, and `react.props` currently raises since `data-props` is no longer plain JSON (it's caret-escaped via `Patch.encode_object`, so `Jason.decode!/1` fails on it). + +- [ ] **Step 3: Update `lib/live_react/test.ex`** + +Replace the body of `get_react/2` (the `is_binary` clause) with: + +```elixir +def get_react(html, opts) when is_binary(html) do + if Code.ensure_loaded?(Floki) do + react = + html + |> Floki.parse_document!() + |> Floki.find("[phx-hook='ReactHook']") + |> find_component!(opts) + + %{ + props: LiveReact.Patch.decode_object(attr(react, "data-props")), + component: attr(react, "data-name"), + id: attr(react, "id"), + slots: extract_base64_slots(attr(react, "data-slots")), + ssr: if(is_nil(attr(react, "data-ssr")), do: false, else: true), + use_diff: attr(react, "data-use-diff") == "true", + class: attr(react, "class"), + props_diff: LiveReact.Patch.deserialize(attr(react, "data-props-diff") || ""), + streams_diff: LiveReact.Patch.deserialize(attr(react, "data-streams-diff") || "") + } + else + raise "Floki is not installed. Add {:floki, \">= 0.30.0\", only: :test} to your dependencies to use LiveReact.Test" + end +end +``` + +Add to the moduledoc, after the existing `## Examples` section: + +```markdown + ## Configuration + + ### enable_props_diff + + When set to `false` in your config, LiveReact will always send full props and not send diffs. + This is useful for testing scenarios where you need to inspect the complete props state + rather than just the changes. + + ```elixir + # config/test.exs + config :live_react, + enable_props_diff: false + ``` + + When disabled, the `props` field returned by `get_react/2` will always contain + the complete props state, making it easier to write comprehensive tests that verify the + full component state rather than just the incremental changes. +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `mix test test/live_react_test_helper_test.exs` +Expected: PASS. + +- [ ] **Step 5: Run the full suite for regressions** + +Run: `mix test` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add lib/live_react/test.ex test/live_react_test_helper_test.exs +git commit -m "feat: expose props_diff/streams_diff/use_diff from LiveReact.Test" +``` + +--- + +### Task 8: JS test infrastructure (Vitest) + +**Files:** +- Modify: `package.json` (repo root) +- Create: `vitest.config.js` (repo root) + +**Interfaces:** +- Consumes: nothing. +- Produces: `npm test` runs Vitest against `assets/**/*.test.js`. Tasks 9-11 depend on this being in place before they can run their JS tests. + +- [ ] **Step 1: Add dev dependencies and scripts to `package.json`** + +```json +{ + "name": "live_react", + "version": "0.1.0", + "description": "E2E reactivity from React and LiveView", + "license": "MIT", + "module": "./assets/js/live_react/index.js", + "exports": { + ".": { + "import": "./assets/js/live_react/index.mjs", + "types": "./assets/js/live_react/index.d.mts" + }, + "./server": "./assets/js/live_react/server.mjs", + "./vite-plugin": "./assets/js/live_react/vite-plugin.js" + }, + "author": "Baptiste Chaleil ", + "repository": { + "type": "git", + "url": "git://github.com/mrdotb/live_react.git" + }, + "files": [ + "README.MD", + "LICENSE.md", + "package.json", + "assets/js/live_react/*" + ], + "devDependencies": { + "jsdom": "^26.1.0", + "prettier": "^3.3.2", + "vitest": "^3.2.4" + }, + "scripts": { + "format": "npx prettier --write .", + "test": "vitest run", + "test:watch": "vitest --watch" + } +} +``` + +- [ ] **Step 2: Create `vitest.config.js`** + +```js +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "jsdom", + globals: true, + include: ["assets/**/*.test.js"], + }, +}); +``` + +- [ ] **Step 3: Install and verify the runner boots with zero tests** + +Run: `npm install && npm test` +Expected: Vitest reports "No test files found" (or passes with 0 test files) — confirms the runner and config are wired correctly before real test files exist. + +- [ ] **Step 4: Commit** + +```bash +git add package.json package-lock.json vitest.config.js +git commit -m "chore: add vitest test runner for client-side JS" +``` + +--- + +### Task 9: `compactPatch.js` + +**Files:** +- Create: `assets/js/live_react/compactPatch.js` +- Test: `assets/js/live_react/compactPatch.test.js` + +**Interfaces:** +- Consumes: nothing (pure decode logic). +- Produces: `decodeCompactPatch(payload: string | null): Array<{op, path, value?}>`, `decodeCompactJson(value: string): any`. Tasks 10 and 11 import both. + +- [ ] **Step 1: Write the failing tests** + +```js +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([]); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm test -- compactPatch` +Expected: FAIL — module `./compactPatch` does not exist. + +- [ ] **Step 3: Implement `compactPatch.js`** + +```js +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 '"'; + }), + ); +}; +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm test -- compactPatch` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add assets/js/live_react/compactPatch.js assets/js/live_react/compactPatch.test.js +git commit -m "feat: add compactPatch decoder for LiveReact patch wire format" +``` + +--- + +### Task 10: `jsonPatch.js` with copy-on-write + +**Files:** +- Create: `assets/js/live_react/jsonPatch.js` +- Test: `assets/js/live_react/jsonPatch.test.js` + +**Interfaces:** +- Consumes: nothing (pure logic module). +- Produces: `applyPatch(document, patch): document` — applies a batch of patch operations, cloning (shallow) the value at each touched top-level key of `document` on first touch, mutating the clone in place, and leaving untouched keys' values byte-for-byte reference-identical to before the call. `getValueByPointer`, `applyOperation` also exported for direct use/testing. Task 11 (`hooks.js`) is the consumer. + +- [ ] **Step 1: Write the failing tests** + +```js +import { describe, it, expect } from "vitest"; +import { getValueByPointer, applyOperation, applyPatch } from "./jsonPatch"; + +describe("getValueByPointer", () => { + const testDoc = { + foo: "bar", + baz: [1, 2, 3], + nested: { key: "value" }, + }; + + it("returns the root document for an empty pointer", () => { + expect(getValueByPointer(testDoc, "")).toBe(testDoc); + }); + + it("gets a simple property", () => { + expect(getValueByPointer(testDoc, "/foo")).toBe("bar"); + }); + + it("gets an array element by index", () => { + expect(getValueByPointer(testDoc, "/baz/1")).toBe(2); + }); + + it("gets the last array element with -", () => { + expect(getValueByPointer(testDoc, "/baz/-")).toBe(3); + }); + + it("gets a nested property", () => { + expect(getValueByPointer(testDoc, "/nested/key")).toBe("value"); + }); +}); + +describe("applyOperation", () => { + it("adds a property to an object", () => { + const doc = { foo: "bar" }; + applyOperation(doc, { op: "add", path: "/baz", value: "qux" }); + expect(doc).toEqual({ foo: "bar", baz: "qux" }); + }); + + it("replaces a property in an object", () => { + const doc = { foo: "bar" }; + applyOperation(doc, { op: "replace", path: "/foo", value: "baz" }); + expect(doc).toEqual({ foo: "baz" }); + }); + + it("removes a property from an object", () => { + const doc = { foo: "bar", baz: "qux" }; + applyOperation(doc, { op: "remove", path: "/foo" }); + expect(doc).toEqual({ baz: "qux" }); + }); + + it("adds an element to an array at a specific index", () => { + const doc = { arr: [1, 2, 3] }; + applyOperation(doc, { op: "add", path: "/arr/1", value: "new" }); + expect(doc).toEqual({ arr: [1, "new", 2, 3] }); + }); + + it("adds an element to the end of an array with -", () => { + const doc = { arr: [1, 2, 3] }; + applyOperation(doc, { op: "add", path: "/arr/-", value: "new" }); + expect(doc).toEqual({ arr: [1, 2, 3, "new"] }); + }); + + it("removes an element from an array", () => { + const doc = { arr: [1, 2, 3] }; + applyOperation(doc, { op: "remove", path: "/arr/1" }); + expect(doc).toEqual({ arr: [1, 3] }); + }); + + it("resolves $$dom_id in array paths for upsert (update)", () => { + const doc = { rows: [{ __dom_id: "a", v: 1 }, { __dom_id: "b", v: 2 }] }; + applyOperation(doc, { op: "upsert", path: "/rows/-", value: { __dom_id: "a", v: 99 } }); + expect(doc.rows).toEqual([{ __dom_id: "a", v: 99 }, { __dom_id: "b", v: 2 }]); + }); + + it("resolves $$dom_id in array paths for upsert (insert)", () => { + const doc = { rows: [{ __dom_id: "a", v: 1 }] }; + applyOperation(doc, { op: "upsert", path: "/rows/-", value: { __dom_id: "c", v: 3 } }); + expect(doc.rows).toEqual([{ __dom_id: "a", v: 1 }, { __dom_id: "c", v: 3 }]); + }); + + it("removes an array element by $$dom_id", () => { + const doc = { rows: [{ __dom_id: "a" }, { __dom_id: "b" }] }; + applyOperation(doc, { op: "remove", path: "/rows/$$a" }); + expect(doc.rows).toEqual([{ __dom_id: "b" }]); + }); + + it("applies limit by trimming from the end for positive values", () => { + const doc = { rows: [1, 2, 3, 4] }; + applyOperation(doc, { op: "limit", path: "/rows", value: 2 }); + expect(doc.rows).toEqual([1, 2]); + }); + + it("applies limit by trimming from the start for negative values", () => { + const doc = { rows: [1, 2, 3, 4] }; + applyOperation(doc, { op: "limit", path: "/rows", value: -2 }); + expect(doc.rows).toEqual([3, 4]); + }); +}); + +describe("applyPatch copy-on-write", () => { + it("gives a fresh array reference for a touched top-level key", () => { + const original = { users: [{ __dom_id: "a", name: "Alice" }] }; + const usersRefBefore = original.users; + + const result = applyPatch(original, [ + { op: "upsert", path: "/users/-", value: { __dom_id: "b", name: "Bob" } }, + ]); + + expect(result.users).not.toBe(usersRefBefore); + expect(result.users).toEqual([ + { __dom_id: "a", name: "Alice" }, + { __dom_id: "b", name: "Bob" }, + ]); + }); + + it("keeps untouched top-level keys reference-identical", () => { + const original = { users: [{ __dom_id: "a" }], posts: [{ __dom_id: "p1" }] }; + const postsRefBefore = original.posts; + + const result = applyPatch(original, [{ op: "remove", path: "/users/$$a" }]); + + expect(result.posts).toBe(postsRefBefore); + }); + + it("keeps unaffected items reference-identical while replacing changed ones", () => { + const alice = { __dom_id: "a", name: "Alice" }; + const bob = { __dom_id: "b", name: "Bob" }; + const original = { users: [alice, bob] }; + + const result = applyPatch(original, [ + { op: "replace", path: "/users/$$b", value: { __dom_id: "b", name: "New Bob" } }, + ]); + + expect(result.users[0]).toBe(alice); + expect(result.users[1]).not.toBe(bob); + expect(result.users[1]).toEqual({ __dom_id: "b", name: "New Bob" }); + }); + + it("only clones a key once across multiple ops in the same batch", () => { + const original = { users: [{ __dom_id: "a" }] }; + + const result = applyPatch(original, [ + { op: "upsert", path: "/users/-", value: { __dom_id: "b" } }, + { op: "upsert", path: "/users/-", value: { __dom_id: "c" } }, + ]); + + expect(result.users.map((u) => u.__dom_id)).toEqual(["a", "b", "c"]); + }); + + it("mutates and returns the same document object (bag identity is stable)", () => { + const original = { users: [] }; + const result = applyPatch(original, [{ op: "upsert", path: "/users/-", value: { __dom_id: "a" } }]); + expect(result).toBe(original); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm test -- jsonPatch` +Expected: FAIL — module `./jsonPatch` does not exist. + +- [ ] **Step 3: Implement `jsonPatch.js`** + +```js +function unescapePathComponent(path) { + return path.replace(/~1/g, "/").replace(/~0/g, "~"); +} + +function resolvePathComponent(component, arrayObj) { + if (!component.startsWith("$$")) { + return component; + } + + const targetId = component.substring(2); + const index = arrayObj.findIndex((item) => item && typeof item === "object" && item.__dom_id == targetId); + + if (index === -1) { + console.warn(`JSON Patch: Item with __dom_id "${targetId}" not found in array, skipping operation`); + return null; + } + + return index.toString(); +} + +function readPathSegment(path, start, end) { + const segment = path.slice(start, end); + return segment.indexOf("~") === -1 ? segment : unescapePathComponent(segment); +} + +function resolveArrayIndex(key, arrayObj, allowAppend) { + if (key.startsWith("$$")) { + const resolved = resolvePathComponent(key, arrayObj); + return resolved === null ? null : parseInt(resolved, 10); + } + + if (key === "-") return allowAppend ? arrayObj.length : arrayObj.length - 1; + return parseInt(key, 10); +} + +export function getValueByPointer(document, pointer) { + if (pointer === "") return document; + + const keys = pointer.split("/").slice(1); + let obj = document; + + for (const key of keys) { + let resolvedKey = key.indexOf("~") !== -1 ? unescapePathComponent(key) : key; + + if (Array.isArray(obj)) { + if (resolvedKey.startsWith("$$")) { + const resolved = resolvePathComponent(resolvedKey, obj); + if (resolved === null) return undefined; + resolvedKey = resolved; + } + obj = obj[resolvedKey === "-" ? obj.length - 1 : parseInt(resolvedKey, 10)]; + } else { + obj = obj[resolvedKey]; + } + } + + return obj; +} + +/** + * Apply a single patch operation on a JSON document in-place. + */ +export function applyOperation(document, operation) { + return applyPatchOperation(document, operation.op, operation.path, operation.value); +} + +function applyPatchOperation(document, op, path, value) { + if (path === "") { + switch (op) { + case "add": + case "replace": + return value; + case "test": + return document; + case "remove": + return null; + } + } + + let obj = document; + let segmentStart = 1; + + while (true) { + const segmentEnd = path.indexOf("/", segmentStart); + if (segmentEnd === -1) break; + + const key = readPathSegment(path, segmentStart, segmentEnd); + + if (Array.isArray(obj)) { + const index = resolveArrayIndex(key, obj, false); + if (index === null) return document; + obj = obj[index]; + } else { + obj = obj[key]; + } + + segmentStart = segmentEnd + 1; + } + + const unescapedKey = readPathSegment(path, segmentStart, path.length); + + if (Array.isArray(obj)) { + const resolvedIndex = resolveArrayIndex(unescapedKey, obj, true); + if (resolvedIndex === null) return document; + const index = resolvedIndex; + + switch (op) { + case "add": + obj.splice(index, 0, value); + break; + case "remove": + obj.splice(index, 1); + break; + case "replace": + obj[index] = value; + break; + case "upsert": + if (value && typeof value === "object" && "__dom_id" in value) { + const existingIndex = obj.findIndex( + (item) => item && typeof item === "object" && item.__dom_id === value.__dom_id, + ); + + if (existingIndex !== -1) { + obj[existingIndex] = value; + } else { + obj.splice(index, 0, value); + } + } else { + obj.splice(index, 0, value); + } + break; + case "test": + break; + case "limit": + if (value >= 0) { + if (value < obj.length) obj.splice(value); + } else { + const keepCount = Math.abs(value); + if (keepCount < obj.length) obj.splice(0, obj.length - keepCount); + } + break; + } + } else { + switch (op) { + case "add": + case "replace": + obj[unescapedKey] = value; + break; + case "remove": + delete obj[unescapedKey]; + break; + case "test": + break; + case "limit": + const targetArray = obj[unescapedKey]; + if (Array.isArray(targetArray)) { + if (value >= 0) { + if (value < targetArray.length) targetArray.splice(value); + } else { + const keepCount = Math.abs(value); + if (keepCount < targetArray.length) targetArray.splice(0, targetArray.length - keepCount); + } + } + break; + } + } + + return document; +} + +function cloneShallow(value) { + if (Array.isArray(value)) return value.slice(); + if (value && typeof value === "object") return { ...value }; + return value; +} + +function firstPathSegment(path) { + const end = path.indexOf("/", 1); + const raw = end === -1 ? path.slice(1) : path.slice(1, end); + return raw.indexOf("~") === -1 ? raw : unescapePathComponent(raw); +} + +/** + * Apply a sequence of patch operations to a document. + * + * Copy-on-write: the value at each patch's first path segment (e.g. `users` + * in `/users/-`) is shallow-cloned the first time an operation in this batch + * touches it, so React sees a fresh reference for anything that actually + * changed while untouched keys (and untouched items within a touched array, + * since per-item replace/upsert already assign fresh item references rather + * than mutating items in place) keep their prior identity. + */ +export function applyPatch(document, patch) { + let result = document; + const touchedKeys = new Set(); + + for (const operation of patch) { + if (operation.path === "") { + result = applyOperation(result, operation); + continue; + } + + const key = firstPathSegment(operation.path); + + if (!touchedKeys.has(key)) { + touchedKeys.add(key); + result[key] = cloneShallow(result[key]); + } + + applyOperation(result, operation); + } + + return result; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm test -- jsonPatch` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add assets/js/live_react/jsonPatch.js assets/js/live_react/jsonPatch.test.js +git commit -m "feat: add copy-on-write patch application for React" +``` + +--- + +### Task 11: `hooks.js` — persistent props/streams state + +**Files:** +- Modify: `assets/js/live_react/hooks.js` +- Create: `assets/js/live_react/tests/helpers.js` +- Test: `assets/js/live_react/hooks.test.js` + +**Interfaces:** +- Consumes: `decodeCompactJson`, `decodeCompactPatch` (Task 9); `applyPatch` (Task 10). +- Produces: `getHooks(components)` behaves as before for consumers, but the `ReactHook` now merges `data-streams-diff` (always) and either `data-props-diff` or full `data-props` (depending on `data-use-diff`) into persistent `_props`/`_streams` state across `mounted`/`updated`/`reconnected`. + +- [ ] **Step 1: Create the mock hook helper** + +```js +// assets/js/live_react/tests/helpers.js +import { vi } from "vitest"; + +let mockIdCounter = 0; + +export const createMockLiveViewHook = (elementAttributes = {}) => { + const id = elementAttributes.id || `mock-${++mockIdCounter}`; + const attributes = { ...elementAttributes }; + + const mockElement = { + id, + getAttribute: vi.fn((name) => (name in attributes ? attributes[name] : null)), + setAttribute: vi.fn((name, value) => { + attributes[name] = value; + }), + hasAttribute: vi.fn((name) => name in attributes), + hasChildNodes: vi.fn(() => false), + }; + + return { + el: mockElement, + pushEvent: vi.fn(), + pushEventTo: vi.fn(), + handleEvent: vi.fn(), + removeHandleEvent: vi.fn(), + upload: vi.fn(), + uploadTo: vi.fn(), + }; +}; +``` + +- [ ] **Step 2: Write the failing tests** + +```js +// assets/js/live_react/hooks.test.js +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createMockLiveViewHook } from "./tests/helpers"; + +const renderMock = vi.fn(); +const rootMock = { render: renderMock, unmount: vi.fn() }; + +vi.mock("react-dom/client", () => ({ + default: { + createRoot: vi.fn(() => rootMock), + hydrateRoot: vi.fn(() => rootMock), + }, +})); + +const TestComponent = () => null; + +function lastRenderedProps() { + // The rendered tree is + const tree = renderMock.mock.calls.at(-1)[0]; + return tree.props.children.props; +} + +// Minimal test-only encoder mirroring LiveReact.Patch.serialize/1 and +// LiveReact.Patch.encode_object/1, so fixtures can't drift from hand-typed +// wire strings. Exercised indirectly by decodeCompactPatch/decodeCompactJson +// (already unit-tested against real Elixir-produced fixtures in Task 9). +const OP_CODES = { add: "a", remove: "d", replace: "r", upsert: "u", limit: "l" }; + +function encodeValue(value) { + if (value === null) return "z"; + if (value === true) return "b1"; + if (value === false) return "b0"; + if (typeof value === "number") { + const s = String(value); + return `n${s.length}:${s}`; + } + if (typeof value === "string") return `s${value.length}:${value}`; + const json = JSON.stringify(value).replace(/"/g, "^"); + return `J${json.length}:${json}`; +} + +function encodePatch(ops) { + return ops + .map(([op, path, value]) => { + const prefix = `${OP_CODES[op]}${path.length}:${path}`; + return op === "remove" ? prefix : prefix + encodeValue(value); + }) + .join(""); +} + +function encodeProps(props) { + return JSON.stringify(props).replace(/"/g, "^"); +} + +describe("ReactHook", () => { + let getHooks; + let ReactHook; + + beforeEach(async () => { + vi.resetModules(); + renderMock.mockClear(); + ({ getHooks } = await import("./hooks")); + ({ ReactHook } = getHooks({ TestComponent })); + }); + + it("merges base props and streams on mount", () => { + const hook = createMockLiveViewHook({ + "data-name": "TestComponent", + "data-props": encodeProps({ title: "Hello" }), + "data-streams-diff": encodePatch([ + ["replace", "/users", []], + ["upsert", "/users/-", { __dom_id: "u1" }], + ]), + }); + + ReactHook.mounted.call(hook); + + const props = lastRenderedProps(); + expect(props.title).toBe("Hello"); + expect(props.users).toEqual([{ __dom_id: "u1" }]); + }); + + it("applies props_diff on update when data-use-diff is true", () => { + const hook = createMockLiveViewHook({ + "data-name": "TestComponent", + "data-props": encodeProps({ title: "Hello" }), + "data-use-diff": "true", + }); + + ReactHook.mounted.call(hook); + + hook.el.getAttribute.mockImplementation((name) => { + if (name === "data-use-diff") return "true"; + if (name === "data-props-diff") return encodePatch([["replace", "/title", "World"]]); + if (name === "data-streams-diff") return null; + return null; + }); + + ReactHook.updated.call(hook); + + expect(lastRenderedProps().title).toBe("World"); + }); + + it("replaces props wholesale on update when data-use-diff is false", () => { + const hook = createMockLiveViewHook({ + "data-name": "TestComponent", + "data-props": encodeProps({ title: "Hello" }), + "data-use-diff": "false", + }); + + ReactHook.mounted.call(hook); + + hook.el.getAttribute.mockImplementation((name) => { + if (name === "data-use-diff") return "false"; + if (name === "data-props") return encodeProps({ title: "Replaced" }); + if (name === "data-streams-diff") return null; + return null; + }); + + ReactHook.updated.call(hook); + + expect(lastRenderedProps().title).toBe("Replaced"); + }); + + it("accumulates stream inserts across updates without losing prior items", () => { + const hook = createMockLiveViewHook({ + "data-name": "TestComponent", + "data-props": encodeProps({}), + "data-streams-diff": encodePatch([ + ["replace", "/users", []], + ["upsert", "/users/-", { __dom_id: "u1" }], + ]), + }); + + ReactHook.mounted.call(hook); + + hook.el.getAttribute.mockImplementation((name) => { + if (name === "data-use-diff") return "true"; + if (name === "data-props-diff") return null; + if (name === "data-streams-diff") return encodePatch([["upsert", "/users/-", { __dom_id: "u2" }]]); + return null; + }); + + ReactHook.updated.call(hook); + + expect(lastRenderedProps().users).toEqual([{ __dom_id: "u1" }, { __dom_id: "u2" }]); + }); + + it("reconnected() re-applies props and streams diffs like updated()", () => { + const hook = createMockLiveViewHook({ + "data-name": "TestComponent", + "data-props": encodeProps({ title: "Hello" }), + "data-streams-diff": encodePatch([ + ["replace", "/users", []], + ["upsert", "/users/-", { __dom_id: "u1" }], + ]), + }); + + ReactHook.mounted.call(hook); + + hook.el.getAttribute.mockImplementation((name) => { + if (name === "data-use-diff") return "true"; + if (name === "data-props-diff") return null; + if (name === "data-streams-diff") return encodePatch([["upsert", "/users/-", { __dom_id: "u2" }]]); + return null; + }); + + ReactHook.reconnected.call(hook); + + expect(lastRenderedProps().users).toEqual([{ __dom_id: "u1" }, { __dom_id: "u2" }]); + }); +}); +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `npm test -- hooks` +Expected: FAIL — `ReactHook.reconnected` is undefined, and props/streams merging doesn't happen yet (current `hooks.js` re-parses `data-props` wholesale every render and has no stream handling). + +- [ ] **Step 4: Update `hooks.js`** + +```js +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"); + + if (!dataSlots?.default) { + return []; + } + + return [ + React.createElement("div", { + dangerouslySetInnerHTML: { __html: atob(dataSlots.default).trim() }, + }), + ]; +} + +function getProps(hook) { + return { + ...hook._props, + ...hook._streams, + pushEvent: hook.pushEvent.bind(hook), + pushEventTo: hook.pushEventTo.bind(hook), + handleEvent: hook.handleEvent.bind(hook), + removeHandleEvent: hook.removeHandleEvent.bind(hook), + upload: hook.upload.bind(hook), + uploadTo: hook.uploadTo.bind(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); + } +} + +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"); + + if (isSSR) { + const tree = getComponentTree(this._Component, getProps(this), getChildren(this)); + this._root = ReactDOM.hydrateRoot(this.el, tree); + } else { + this._root = ReactDOM.createRoot(this.el); + this._render(); + } + }, + updated() { + if (this._root) { + refreshProps(this); + refreshStreams(this); + this._render(); + } + }, + reconnected() { + if (this._root) { + refreshProps(this); + refreshStreams(this); + this._render(); + } + }, + destroyed() { + if (this._root) { + window.addEventListener("phx:page-loading-stop", () => this._root.unmount(), { once: true }); + } + }, + }; + + return { ReactHook }; +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `npm test -- hooks` +Expected: PASS. + +- [ ] **Step 6: Run the full JS suite for regressions** + +Run: `npm test` +Expected: PASS (all of `compactPatch.test.js`, `jsonPatch.test.js`, `hooks.test.js` green). + +- [ ] **Step 7: Commit** + +```bash +git add assets/js/live_react/hooks.js assets/js/live_react/hooks.test.js assets/js/live_react/tests/helpers.js +git commit -m "feat: apply props/streams diffs with persistent hook state, add reconnected()" +``` + +--- + +### Task 12: CHANGELOG entry + +**Files:** +- Modify: `CHANGELOG.md` + +**Interfaces:** +- Consumes: nothing. +- Produces: nothing consumed by later tasks — documentation only. + +- [ ] **Step 1: Read the top of `CHANGELOG.md` to match its existing format** + +Run: `head -n 30 CHANGELOG.md` + +- [ ] **Step 2: Add an "Unreleased" entry above the most recent version heading** + +Add a section (matching whatever heading style the existing file uses, typically `## [Unreleased]` or a new version heading per this project's `git_ops`-managed convention) with: + +```markdown +### 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`. +``` + +- [ ] **Step 3: Commit** + +```bash +git add CHANGELOG.md +git commit -m "docs: changelog entry for props diffing and stream support" +``` + +--- + +### Task 13: Example app demo (manual verification) + +**Files:** +- Create: `live_react_examples/lib/live_react_examples_web/live/stream_demo_live.ex` +- Create: `live_react_examples/assets/react-components/stream-demo.jsx` +- Modify: `live_react_examples/assets/react-components/index.js` +- Modify: `live_react_examples/lib/live_react_examples_web/router.ex` + +**Interfaces:** +- Consumes: `<.react streams={@messages} .../>` (the completed feature from Tasks 1-11). +- Produces: a `/stream-demo` route for manual browser verification. Nothing else depends on this task; it exists purely to drive the feature end-to-end with `mix phx.server` per this repo's `/verify` conventions. + +- [ ] **Step 1: Check the router for the existing route pattern** + +Run: `grep -n "live \"/" live_react_examples/lib/live_react_examples_web/router.ex` + +- [ ] **Step 2: Add the LiveView** + +```elixir +defmodule LiveReactExamplesWeb.StreamDemoLive do + use LiveReactExamplesWeb, :live_view + + def mount(_params, _session, socket) do + socket = + socket + |> assign(:next_id, 1) + |> stream(:messages, [%{id: 0, text: "Welcome!"}]) + + {:ok, socket} + end + + def handle_event("add", _params, socket) do + id = socket.assigns.next_id + socket = socket |> assign(:next_id, id + 1) |> stream_insert(:messages, %{id: id, text: "Message #{id}"}) + {:noreply, socket} + end + + def handle_event("delete", %{"id" => id}, socket) do + {:noreply, stream_delete(socket, :messages, %{id: String.to_integer(id)})} + end + + def render(assigns) do + ~H""" + <.react name="StreamDemo" messages={@streams.messages} socket={@socket} /> + """ + end +end +``` + +- [ ] **Step 3: Add the React component** + +```jsx +// live_react_examples/assets/react-components/stream-demo.jsx +export default function StreamDemo({ messages, pushEvent }) { + return ( +
+ +
    + {messages.map((message) => ( +
  • + {message.text} + +
  • + ))} +
+
+ ); +} +``` + +- [ ] **Step 4: Register the component** + +In `live_react_examples/assets/react-components/index.js`, add: + +```js +export { default as StreamDemo } from "./stream-demo.jsx"; +``` + +- [ ] **Step 5: Add the route** + +In `live_react_examples/lib/live_react_examples_web/router.ex`, alongside the other `live "/..."` entries: + +```elixir +live "/stream-demo", StreamDemoLive +``` + +- [ ] **Step 6: Build assets and boot the server** + +Run (from `live_react_examples/`): `mix setup && mix phx.server` +Expected: server boots without errors. + +- [ ] **Step 7: Manually verify in the browser** + +Visit `http://localhost:4000/stream-demo`. Click "Add message" several times and confirm new list items appear without a full-page/full-list re-render (check via React DevTools "Highlight updates" that only the new row flashes, not the whole list). Click "Delete" on a middle item and confirm only that row disappears. + +- [ ] **Step 8: Commit** + +```bash +git add live_react_examples/lib/live_react_examples_web/live/stream_demo_live.ex \ + live_react_examples/assets/react-components/stream-demo.jsx \ + live_react_examples/assets/react-components/index.js \ + live_react_examples/lib/live_react_examples_web/router.ex +git commit -m "docs: add stream demo LiveView for manual verification" +``` + +--- + +### Task 14: `Phoenix.HTML.Form` Encoder implementation + +**Added post-hoc:** the original design spec (`docs/superpowers/specs/2026-07-13-props-diff-and-streams-design.md`, §2) called for a `Phoenix.HTML.Form` implementation of `LiveReact.Encoder`, matching `LiveVue.Encoder`'s (Ecto-changeset-aware, conditional on `Code.ensure_loaded?(Ecto)`), but Task 3 silently narrowed scope to just `AsyncResult`/`UploadConfig`/`UploadEntry`. Caught by the final whole-branch review. This task closes that gap. + +**Files:** +- Modify: `lib/live_react/encoder.ex` (append only — do not touch Tasks 2/3's existing content) +- Modify: `mix.exs` (add `{:ecto, "~> 3.0", optional: true}`, `{:phoenix_ecto, "~> 4.0", optional: true}`) +- Test: `test/live_react_encoder_form_test.exs` + +**Interfaces:** +- Consumes: `LiveReact.Encoder.encode/2` (Task 2). +- Produces: `LiveReact.Encoder` implementation for `Phoenix.HTML.Form`. No new public functions. + +- [ ] **Step 1: Add the optional deps** + +In `mix.exs`, add to `defp deps do` (both `optional: true`, no `only:` restriction so they're available in `:test` env for this task's own tests): + +```elixir +{:ecto, "~> 3.0", optional: true}, +{:phoenix_ecto, "~> 4.0", optional: true}, +``` + +Run: `mix deps.get` + +- [ ] **Step 2: Write the failing tests** + +```elixir +defmodule LiveReact.EncoderFormTest do + use ExUnit.Case + + import Ecto.Changeset + import Phoenix.Component, only: [to_form: 2] + + alias LiveReact.Encoder + alias Phoenix.HTML.FormData + + defp encode_form(source, attrs) do + module = source.__struct__ + changeset = module.changeset(source, attrs) + form = FormData.to_form(changeset, as: module.__schema__(:source)) + Encoder.encode(form) + end + + defmodule Simple do + @moduledoc false + use Ecto.Schema + + import Ecto.Changeset + + @derive {Encoder, except: [:secret]} + embedded_schema do + field(:name, :string) + field(:secret, :string) + field(:age, :integer) + field(:active, :boolean) + field(:tags, {:array, :string}) + field(:score, :float) + end + + def changeset(simple, attrs) do + simple + |> cast(attrs, [:name, :secret, :age, :active, :tags, :score]) + |> validate_required([:name]) + |> validate_number(:age, greater_than: 0) + |> validate_number(:score, greater_than_or_equal_to: 0, less_than_or_equal_to: 100) + end + end + + describe "Phoenix.HTML.Form encoding — Ecto changeset backed" do + test "encodes form with simple values" do + simple = %Simple{} + attrs = %{name: "John", secret: "hidden_value", age: 30, active: true, tags: ["elixir", "phoenix"], score: 95.5} + encoded = encode_form(simple, attrs) + + assert encoded == %{ + name: "simple", + values: %{ + id: nil, + name: "John", + age: 30, + active: true, + tags: ["elixir", "phoenix"], + score: 95.5 + }, + errors: %{}, + valid: true + } + end + + test "encodes form with validation errors" do + simple = %Simple{} + attrs = %{name: nil, age: -5, score: 150} + encoded = encode_form(simple, attrs) + + assert encoded.name == "simple" + assert encoded.valid == false + + assert encoded.values == %{ + id: nil, + name: nil, + age: -5, + active: nil, + tags: nil, + score: 150 + } + + assert encoded.errors == %{ + name: ["can't be blank"], + age: ["must be greater than 0"], + score: ["must be less than or equal to 100"] + } + end + end + + describe "Phoenix.HTML.Form encoding — plain map backed (no Ecto)" do + test "encodes form backed by simple map data" do + form_data = %{ + "name" => "John Doe", + "email" => "john@example.com", + "role" => "developer", + "bio" => "Software engineer with 5 years experience", + "notifications" => true + } + + form = to_form(form_data, as: :user) + encoded = Encoder.encode(form) + + assert encoded == %{ + name: "user", + values: %{ + "name" => "John Doe", + "email" => "john@example.com", + "role" => "developer", + "bio" => "Software engineer with 5 years experience", + "notifications" => true + }, + errors: %{}, + valid: true + } + end + + test "encodes form with empty map data" do + form_data = %{ + "name" => "", + "email" => "", + "role" => "", + "bio" => "", + "notifications" => false + } + + form = to_form(form_data, as: :profile) + encoded = Encoder.encode(form) + + assert encoded == %{ + name: "profile", + values: %{ + "name" => "", + "email" => "", + "role" => "", + "bio" => "", + "notifications" => false + }, + errors: %{}, + valid: true + } + end + end +end +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `mix test test/live_react_encoder_form_test.exs` +Expected: FAIL — `Protocol.UndefinedError` for `Phoenix.HTML.Form`. + +- [ ] **Step 4: Append the Form encoder implementation** + +Append to `lib/live_react/encoder.ex` (after the LiveView struct impls from Task 3): + +```elixir +defimpl LiveReact.Encoder, for: Phoenix.HTML.Form do + def encode(%Phoenix.HTML.Form{} = form, opts) do + LiveReact.Encoder.encode( + %{ + name: form.name, + values: encode_form_values(form, opts), + errors: encode_form_errors(form) || %{}, + valid: get_form_validity(form) + }, + opts + ) + end + + defp get_form_validity(%{source: %{valid?: valid}}), do: valid + defp get_form_validity(_), do: true + + if Code.ensure_loaded?(Ecto) do + @relations [:embed, :assoc] + + defp collect_changeset_values(%Ecto.Changeset{} = source, opts) do + data = Map.new(source.types, fn {field, type} -> {field, get_field_value(source, field, type, opts)} end) + result = if is_struct(source.data), do: Map.merge(source.data, data), else: data + Map.delete(result, :__meta__) + end + + defp get_field_value(source, field, {tag, %{cardinality: :one}}, opts) when tag in @relations do + case Map.fetch(source.changes, field) do + {:ok, nil} -> + nil + + {:ok, %Ecto.Changeset{} = changeset} -> + collect_changeset_values(changeset, opts) + + :error -> + case Map.fetch!(source.data, field) do + %Ecto.Association.NotLoaded{} = not_loaded -> + if opts[:nilify_not_loaded], do: nil, else: not_loaded + + %{__meta__: _} = value -> + Map.delete(value, :__meta__) + + value -> + value + end + end + end + + defp get_field_value(source, field, {tag, %{cardinality: :many}}, opts) when tag in @relations do + case Map.fetch(source.changes, field) do + {:ok, changesets} -> + changesets + |> Enum.filter(&(&1.params != nil)) + |> Enum.map(&collect_changeset_values(&1, opts)) + + :error -> + case Map.fetch!(source.data, field) do + %Ecto.Association.NotLoaded{} = not_loaded -> + if opts[:nilify_not_loaded], do: nil, else: not_loaded + + [%{__meta__: _} | _] = value -> + Enum.map(value, &Map.delete(&1, :__meta__)) + + value -> + value + end + end + end + + defp get_field_value(source, field, _type, _opts) do + Phoenix.HTML.FormData.Ecto.Changeset.input_value(source, %{params: source.params}, field) + end + + def encode_form_values(%{impl: Phoenix.HTML.FormData.Ecto.Changeset, source: source}, opts) do + source |> collect_changeset_values(opts) |> LiveReact.Encoder.encode(opts) + end + end + + def encode_form_values(form, opts) do + base_values = + form.hidden + |> Map.new() + |> Map.merge(form.data) + |> Map.merge(Map.new(form.params)) + + LiveReact.Encoder.encode(base_values, opts) + end + + if Code.ensure_loaded?(Ecto) do + defp collect_changeset_errors(%Ecto.Changeset{} = changeset) do + errors = translate_errors(changeset.errors) + + Enum.reduce(changeset.changes, errors, fn {field, value}, acc -> + case Map.get(changeset.types, field) do + {tag, %{cardinality: :one}} when tag in @relations -> + embed_errors = collect_changeset_errors(value) + if embed_errors == %{}, do: acc, else: Map.put(acc, field, embed_errors) + + {tag, %{cardinality: :many}} when tag in @relations -> + list_errors = + value + |> Enum.filter(&(&1.params != nil)) + |> Enum.map(fn embed_changeset -> + embed_errors = collect_changeset_errors(embed_changeset) + if embed_errors == %{}, do: nil, else: embed_errors + end) + + if Enum.all?(list_errors, &is_nil/1), do: acc, else: Map.put(acc, field, list_errors) + + _ -> + acc + end + end) + end + + def encode_form_errors(%{impl: Phoenix.HTML.FormData.Ecto.Changeset} = form) do + collect_changeset_errors(form.source) + end + end + + def encode_form_errors(form) do + translate_errors(form.errors) + end + + defp translate_errors(errors) do + Map.new(errors, fn {field, error} -> {field, error |> List.wrap() |> Enum.map(&translate_error/1)} end) + end + + defp translate_error({msg, opts}) do + Enum.reduce(opts, msg, fn {key, value}, acc -> + String.replace( + acc, + "%{#{key}}", + value + |> List.wrap() + |> Enum.map_join(", ", fn + v when is_binary(v) or is_atom(v) or is_number(v) -> to_string(v) + v -> inspect(v) + end) + ) + end) + end +end +``` + +Note: this port intentionally drops `LiveVue.Encoder`'s optional Gettext-backend error translation (`Application.get_env(:live_vue, :gettext_backend, ...)`) — live_react has no equivalent existing Gettext integration point in this codebase, so it's out of scope here; `translate_error/1`'s plain string-interpolation fallback (the same one live_vue itself falls back to when no Gettext backend is configured) is sufficient for parity with what most `LiveReact` users need. + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `mix test test/live_react_encoder_form_test.exs` +Expected: PASS. + +- [ ] **Step 6: Run the full suite for regressions** + +Run: `mix test` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add lib/live_react/encoder.ex mix.exs mix.lock test/live_react_encoder_form_test.exs +git commit -m "feat: encode Phoenix.HTML.Form via LiveReact.Encoder" +``` diff --git a/docs/superpowers/specs/2026-07-13-props-diff-and-streams-design.md b/docs/superpowers/specs/2026-07-13-props-diff-and-streams-design.md new file mode 100644 index 00000000..1b0ce955 --- /dev/null +++ b/docs/superpowers/specs/2026-07-13-props-diff-and-streams-design.md @@ -0,0 +1,192 @@ +# Props Diffing & LiveView Streams for live_react + +Date: 2026-07-13 +Status: Approved, ready for implementation planning + +## 1. Overview & Goals + +Port two related features from `live_vue` to `live_react`: + +1. **Props diffing** — instead of re-sending the full `data-props` JSON on every + LiveView update, send only a JSON-Patch-style diff (`data-props-diff`), + computed server-side via the `Jsonpatch` library. +2. **Stream support** — automatically detect `%Phoenix.LiveView.LiveStream{}` + assigns (from `Phoenix.LiveView.stream/3,4`) and serialize their + inserts/deletes/resets as patch operations over a separate + `data-streams-diff` channel. This is the *only* way to expose streams at + all, since a `LiveStream` never holds a full snapshot server-side by + design — there is no "full props" fallback for stream data. + +Both diffs share one wire format (a compact custom binary encoding, cheaper +than raw JSON) and one JSON-Patch-like operation vocabulary (`add` / `remove` +/ `replace` / `upsert` / `limit`). + +The central design fork from `live_vue`: Vue applies patches by mutating a +`reactive()` proxy in place, and Vue's reactivity system auto-detects the +mutation. React has no equivalent — components (especially `React.memo`'d +ones) key off object/array *identity* to decide whether to re-render. The +client therefore applies patches **copy-on-write**: the same patch-walking +logic as `live_vue`, but the top-level structure being patched is cloned +before mutation on every update, so: + +- Unaffected items keep their old reference (memoized rows skip re-render). +- Anything actually touched (replaced/inserted/removed) gets a fresh + reference, so React always sees the right thing changed. + +## 2. Elixir-side architecture + +### New modules (`lib/live_react/`) + +- **`LiveReact.Encoder`** — protocol ported from `LiveVue.Encoder`. Converts + structs into plain maps before diffing (`Jsonpatch.diff/3` needs real maps, + not opaque struct terms). Includes: + - Primitive impls: `Integer`, `Float`, `BitString`, `Atom`, `List`, `Map`, + the `Date`/`Time`/`NaiveDateTime`/`DateTime` family (encoded via + `to_iso8601/1` so diffing compares strings, not tuples). + - LiveView-specific impls: `Phoenix.HTML.Form`, `Phoenix.LiveView.AsyncResult`, + `Phoenix.LiveView.UploadConfig`, `Phoenix.LiveView.UploadEntry`. The + Ecto-changeset-aware parts of the `Form` impl stay conditional on + `Code.ensure_loaded?(Ecto)`, matching `live_vue`'s existing optional-dep + pattern (`{:ecto, optional: true}`, `{:phoenix_ecto, optional: true}`). + - `Any` fallback raises `Protocol.UndefinedError` with a message pointing + users at `@derive {LiveReact.Encoder, only: [...]}` / + `Protocol.derive/3`, same as `live_vue`. + +- **`LiveReact.Patch`** — ported near-verbatim from `LiveVue.Patch`; it's pure + Elixir with nothing Vue-specific. Serializes/deserializes the compact patch + wire format (`:` with tagged values: `z`/`b0`/ + `b1`/`n:`/`s:`/`J:`), plus `encode_object/1` / + `decode_object/1` for the caret-escaped JSON attribute encoding used for + `data-props`/`data-slots`-style full-value attributes. + +### Changes to `lib/live_react.ex` (`react/1`) + +- Auto-detect `%Phoenix.LiveView.LiveStream{}` values in assigns (any key + name, matching `live_vue`'s approach) and route them into a `streams` map + instead of `props`. +- Add `@diff_default = Application.compile_env(:live_react, :enable_props_diff, true)`. + Overridable per-instance with a `diff={false}` attribute, mirroring the + existing `ssr={false}` convention. +- Compute `props_diff` via `Jsonpatch.diff/3`, using `LiveReact.Encoder.encode/1` + as the `prepare_map` callback and an `:id`-field-based `object_hash` + (`object_hash(%{id: id}), do: id`). +- Compute `streams_diff` by porting `calculate_streams_diff/2` and + `generate_stream_patches/2` from `live_vue` verbatim: handles `reset?`, + `deletes`, and `inserts` (respecting `at`/`limit`/`update_only`), no + `Jsonpatch.diff` needed here since `Phoenix.LiveView.LiveStream` already + hands us pre-computed inserts/deletes. +- Both diffs are serialized through `LiveReact.Patch.serialize/1` into + `data-props-diff` / `data-streams-diff` attributes. +- Switch `data-props` encoding from raw `Jason.encode!/1` to + `Encoder.encode(@props) |> Patch.encode_object()`, so the full snapshot and + the incremental diffs stay structurally consistent. **This is a breaking + change** for existing struct props — see §5. +- Rework `__changed__` bookkeeping so `data-props` is only marked changed on + init / dead-render / diff-disabled; otherwise only `data-props-diff` + carries updates. Add a `data-use-diff` attribute so the client knows + whether to expect diffs or full replacement. +- `mix.exs`: add `{:jsonpatch, "~> 2.3"}`. + +### Explicitly unchanged + +- Slot handling (`LiveReact.Slots`) is untouched. +- SSR path (`SSR.render/3`) keeps receiving only `assigns.props` — streams are + excluded from SSR, matching `live_vue`'s existing limitation. SSR'd HTML + will not include stream items until the client hydrates and applies the + initial `data-streams-diff`. + +## 3. JS/client architecture + +### New files (`assets/js/live_react/`) + +live_react's client library is plain JS/JSX (no TypeScript build step for the +library itself, unlike `live_vue`'s `.ts` sources), so ports are written as +plain JS: + +- **`compactPatch.js`** — port of `decodeCompactPatch`/`decodeCompactJson` + verbatim. Pure decode logic, framework-agnostic, no changes needed. +- **`jsonPatch.js`** — port of `applyPatchOperation` / `applyPatch` / + `getValueByPointer`, with one behavioral change: instead of mutating + `document` in place, callers clone the top-level value being patched + (shallow `{...obj}` / `[...arr]`, or `structuredClone` if a deep clone is + ever warranted) before running the same op-application logic against the + clone. Nested `replace`/`upsert` ops already swap in fresh objects at the + point of change, so only the *outermost* container's reference needs + refreshing for React to detect the update — unaffected leaf items keep + their prior reference. + +### `hooks.js` changes + +The hook keeps two persistent pieces of state per instance: + +- `this._props` — base props. Replaced wholesale on init / dead-render / + diff-disabled; patched via `data-props-diff` otherwise. +- `this._streams` — never sent as a full snapshot. Built purely by applying + `data-streams-diff` patches starting from `{}`, since that's the only + channel stream data travels over. + +Lifecycle: + +- `mounted()`: parse `data-props` as today, then apply the initial + `data-streams-diff` (server sends this as reset-then-insert-all on first + render, same as `live_vue`) to seed `this._streams`. Merge both into props + for the first render. +- `updated()`: if `data-use-diff === "true"`, apply `data-props-diff` + (copy-on-write) to `this._props`; otherwise replace it wholesale from + `data-props` (today's behavior, preserved as the diff-disabled path). + Always apply `data-streams-diff` (copy-on-write) to `this._streams`. + Re-render with `{...this._props, ...this._streams, pushEvent, pushEventTo, + handleEvent, removeHandleEvent, upload, uploadTo}`. +- **New `reconnected()` handler** (live_react's hook does not define one + today) — same handling as `updated()`. Necessary addition: without it, a + server-process restart mid-session would not refresh diff/stream state on + the client. +- `destroyed()`: unchanged. + +## 4. Testing impact + +`LiveReact.Test.get_react/2` currently always decodes `data-props` as the +full current state. Under diff mode that attribute stops being resent after +the initial render, so the helper needs updating — following `live_vue`'s +precedent directly rather than inventing something new: + +- Expose new fields on the returned map: `props_diff`, `streams_diff` (raw + deserialized patch ops via `LiveReact.Patch.deserialize/1`), `use_diff`. +- Document the escape hatch in moduledoc: setting + `config :live_react, enable_props_diff: false` (e.g. in `config/test.exs`) + makes `data-props` always carry the full snapshot, for tests that want to + assert complete state rather than incremental diffs. +- Keep `Floki`-based parsing (`live_vue` switched to `LazyHTML`, but that's an + unrelated, orthogonal change with no bearing on this feature). + +Out of scope for this change: `LiveReact.Test`'s moduledoc currently +mentions a `:handlers` / `v-on:*` / `JS.push` concept copy-pasted from +`live_vue`'s docs that doesn't match live_react's actual implementation +(live_react has no handlers concept — it uses `pushEvent` functions +instead). Not being touched here; flagged as a pre-existing doc inconsistency +for a separate cleanup. + +## 5. Breaking changes / migration + +- **Struct props now require `LiveReact.Encoder`** instead of just + `Jason.Encoder`, since `data-props` switches to + `Encoder.encode/1 |> Patch.encode_object/1`. Any existing app passing a + struct prop that only implements `Jason.Encoder` (or derives it) will start + raising `Protocol.UndefinedError` until it `@derive`s `LiveReact.Encoder` + (plain maps need no changes). This is the headline upgrade action and + should be called out prominently in the CHANGELOG. +- `enable_props_diff` defaults to `true` (matching `live_vue`) — diffing + turns on for everyone upgrading, not opt-in. This is a behavior change on + upgrade (smaller wire payloads, but relies on the new `LiveReact.Encoder` + protocol for any struct props), not purely additive. + +## 6. Out of scope + +Not porting, since they're Vue-template-specific conveniences with no React +equivalent need: + +- `live_vue`'s `handlers` / `v-on:*` JS-command props — React uses + `pushEvent` functions instead, already an existing live_react concept, + unrelated to streams/diff. +- `v-inject` (slot-injection-into-another-component) feature. +- `LiveVue.SharedPropsView`. diff --git a/lib/live_react.ex b/lib/live_react.ex index b45a6136..c5211def 100644 --- a/lib/live_react.ex +++ b/lib/live_react.ex @@ -6,13 +6,17 @@ defmodule LiveReact do use Phoenix.Component import Phoenix.HTML - alias Phoenix.LiveView + alias LiveReact.Encoder + alias LiveReact.Patch alias LiveReact.SSR alias LiveReact.Slots + alias Phoenix.LiveView + alias Phoenix.LiveView.LiveStream require Logger @ssr_default Application.compile_env(:live_react, :ssr, true) + @diff_default Application.compile_env(:live_react, :enable_props_diff, true) @doc """ Render a React component. @@ -20,18 +24,35 @@ defmodule LiveReact do def react(assigns) do init = assigns.__changed__ == nil dead = assigns[:socket] == nil or not LiveView.connected?(assigns[:socket]) + use_diff = Map.get(assigns, :diff, @diff_default) + use_streams_diff = Enum.any?(assigns, fn {_k, v} -> match?(%LiveStream{}, v) end) render_ssr? = init and dead and Map.get(assigns, :ssr, @ssr_default) - # we manually compute __changed__ for the computed props and slots so it's not sent without reason - {props, props_changed?} = extract(assigns, :props) - {slots, slots_changed?} = extract(assigns, :slots) + base_assigns = + if use_diff do + Enum.filter(assigns, fn {k, _v} -> key_changed(assigns, k) end) + else + assigns + end + + {props, _} = extract(base_assigns, assigns, :props) + {streams, _} = extract(base_assigns, assigns, :streams) + {slots, slots_changed?} = extract(assigns, assigns, :slots) component_name = Map.get(assigns, :name) + props_diff = if use_diff, do: calculate_props_diff(props, assigns), else: [] + + streams_diff = + if use_streams_diff, do: calculate_streams_diff(streams, init or dead), else: [] + assigns = assigns |> Map.put_new(:class, nil) |> Map.put(:__component_name, component_name) |> Map.put(:props, props) + |> Map.put(:props_diff, Patch.serialize(props_diff)) + |> Map.put(:streams_diff, Patch.serialize(streams_diff)) + |> Map.put(:use_diff, use_diff) |> Map.put(:slots, if(slots_changed?, do: Slots.rendered_slot_map(slots), else: %{})) assigns = @@ -39,9 +60,11 @@ defmodule LiveReact do computed_changed = %{ - props: props_changed?, + props: init or dead or not use_diff, slots: slots_changed?, - ssr_render: render_ssr? + ssr_render: render_ssr?, + props_diff: not init and not dead and use_diff, + streams_diff: use_streams_diff } assigns = @@ -55,9 +78,12 @@ defmodule LiveReact do
Slots.base_encode_64 |> json}"} data-ssr={is_map(@ssr_render)} + data-use-diff={@use_diff |> to_string()} phx-update="ignore" phx-hook="ReactHook" class={@class} @@ -65,20 +91,111 @@ defmodule LiveReact do """ end - defp extract(assigns, type) do - Enum.reduce(assigns, {%{}, false}, fn {key, value}, {acc, changed} -> + # Calculates minimal JSON Patch operations for changed props only. + # Uses Phoenix LiveView's __changed__ tracking to identify what props have changed. + defp calculate_props_diff(props, %{__changed__: changed}) do + props + |> Enum.flat_map(fn {k, new_value} -> + case changed[k] do + nil -> + [] + + true -> + [%{op: "replace", path: "/#{k}", value: Encoder.encode(new_value)}] + + old_value -> + Jsonpatch.diff(old_value, new_value, + ancestor_path: "/#{k}", + prepare_map: fn + struct when is_struct(struct) -> Encoder.encode(struct) + rest -> rest + end, + object_hash: &object_hash/1 + ) + end + end) + |> then(fn diff -> [%{op: "test", path: "", value: :rand.uniform(10_000_000)} | diff] end) + end + + defp object_hash(%{id: id}), do: id + defp object_hash(_), do: nil + + # Generates JSON patch operations for LiveStream changes. + # Handles insertions and deletions for Phoenix LiveView streams. + defp calculate_streams_diff(streams, initial) + + defp calculate_streams_diff(streams, true) do + # for initial render, we want to reset all streams, and then apply the diffs + init = Enum.map(streams, fn {k, _} -> %{op: "replace", path: "/#{k}", value: []} end) + diffs = Enum.flat_map(streams, fn {k, stream} -> generate_stream_patches(k, stream) end) + init ++ diffs + end + + defp calculate_streams_diff(streams, false) do + streams + |> Enum.flat_map(fn {k, stream} -> generate_stream_patches(k, stream) end) + |> then(fn diff -> [%{op: "test", path: "", value: :rand.uniform(10_000_000)} | diff] end) + end + + # Generates JSON patch operations for a single LiveStream's changes. + defp generate_stream_patches(stream_name, %LiveStream{} = stream) do + patches = [] + + patches = + if stream.reset?, + do: [%{op: "replace", path: "/#{stream_name}", value: []} | patches], + else: patches + + patches = + Enum.reduce(stream.deletes, patches, fn dom_id, patches -> + [%{op: "remove", path: "/#{stream_name}/$$#{dom_id}"} | patches] + end) + + # Reversed - inserts at -1 should be correctly ordered, inserts at 0 should be reversed + # see https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#stream/4 :at option + stream.inserts + |> Enum.reverse() + |> Enum.reduce(patches, fn {dom_id, at, item, limit, update_only}, patches -> + item = Map.put(Encoder.encode(item), :__dom_id, dom_id) + + patches = + if update_only, + do: [%{op: "replace", path: "/#{stream_name}/$$#{dom_id}", value: item} | patches], + else: [ + %{ + op: "upsert", + path: "/#{stream_name}/#{if at == -1, do: "-", else: at}", + value: item + } + | patches + ] + + if limit, + do: [%{op: "limit", path: "/#{stream_name}", value: limit} | patches], + else: patches + end) + |> Enum.reverse() + end + + # `iterable` is the (possibly diff-filtered) collection of assigns to bucket by `type`. + # `source` is always the original, unfiltered assigns map (with `__changed__` intact), + # used for the `key_changed/2` lookups below regardless of what `iterable` is. + defp extract(iterable, source, type) do + Enum.reduce(iterable, {%{}, false}, fn {key, value}, {acc, changed} -> case normalize_key(key, value) do - ^type -> {Map.put(acc, key, value), changed || key_changed(assigns, key)} + ^type -> {Map.put(acc, key, value), changed || key_changed(source, key)} _ -> {acc, changed} end end) end - defp normalize_key(key, _val) when key in ~w(id class ssr name socket __changed__ __given__)a, - do: :special + defp normalize_key(key, _val) + when key in ~w(id class ssr diff name socket __changed__ __given__)a, + do: :special defp normalize_key(_key, [%{__slot__: _}]), do: :slots defp normalize_key(key, val) when is_atom(key), do: key |> to_string() |> normalize_key(val) + defp normalize_key(_key, %LiveStream{}), do: :streams defp normalize_key(_key, _val), do: :props defp key_changed(%{__changed__: nil}, _key), do: true @@ -88,7 +205,7 @@ defmodule LiveReact do try do name = Map.get(assigns, :name) - SSR.render(name, assigns.props, assigns.slots) + SSR.render(name, Encoder.encode(assigns.props), assigns.slots) rescue SSR.NotConfigured -> nil diff --git a/lib/live_react/encoder.ex b/lib/live_react/encoder.ex new file mode 100644 index 00000000..86c02890 --- /dev/null +++ b/lib/live_react/encoder.ex @@ -0,0 +1,394 @@ +defprotocol LiveReact.Encoder do + @moduledoc """ + Protocol for encoding values to JSON for LiveReact. + + This protocol is used to safely transform structs into plain maps before + calculating JSON patches. It ensures that struct fields are explicitly + exposed and prevents accidental exposure of sensitive data. + + It's very similar to Jason.Encoder, but it's converting structs to maps instead of strings. + + ## Deriving + + The protocol allows leveraging Elixir's `@derive` feature to simplify protocol + implementation in trivial cases. Accepted options are: + + * `:only` - encodes only values of specified keys. + * `:except` - encodes all struct fields except specified keys. + + By default all keys except the `:__struct__` key are encoded. + + ## Example + + defmodule User do + @derive LiveReact.Encoder + defstruct [:name, :email, :password] + end + + If we called `@derive {LiveReact.Encoder, only: [:name, :email]}`, only the + specified fields would be encoded. If we called + `@derive {LiveReact.Encoder, except: [:password]}`, all fields except the + specified ones would be encoded. + + ## Deriving outside of the module + + Protocol.derive(LiveReact.Encoder, User, only: [...]) + + ## Custom implementations + + defimpl LiveReact.Encoder, for: User do + def encode(struct, opts) do + struct + |> Map.take([:first, :second]) + |> LiveReact.Encoder.encode(opts) + end + end + """ + + @type t :: term + @type opts :: Keyword.t() + @fallback_to_any true + + @doc """ + Encodes a value to one of the primitive types. + """ + @spec encode(t, opts) :: any() + def encode(value, opts \\ []) +end + +defimpl LiveReact.Encoder, for: Integer do + def encode(value, _opts), do: value +end + +defimpl LiveReact.Encoder, for: Float do + def encode(value, _opts), do: value +end + +defimpl LiveReact.Encoder, for: BitString do + def encode(value, _opts), do: value +end + +defimpl LiveReact.Encoder, for: Atom do + def encode(atom, _opts), do: atom +end + +defimpl LiveReact.Encoder, for: List do + def encode(list, opts) do + Enum.map(list, &LiveReact.Encoder.encode(&1, opts)) + end +end + +defimpl LiveReact.Encoder, for: Map do + def encode(map, opts) do + Map.new(map, fn {key, value} -> + {key, LiveReact.Encoder.encode(value, opts)} + end) + end +end + +defimpl LiveReact.Encoder, for: [Date, Time, NaiveDateTime, DateTime] do + def encode(value, _opts) do + @for.to_iso8601(value) + end +end + +defimpl LiveReact.Encoder, for: Any do + defmacro __deriving__(module, struct, opts) do + fields = fields_to_encode(struct, opts) + + quote do + defimpl LiveReact.Encoder, for: unquote(module) do + def encode(struct, opts) do + struct + |> Map.take(unquote(fields)) + |> LiveReact.Encoder.encode(opts) + end + end + end + end + + def encode(%{__struct__: module} = struct, _opts) do + raise Protocol.UndefinedError, + protocol: @protocol, + value: struct, + description: """ + LiveReact.Encoder protocol must always be explicitly implemented. + + It's used to encode structs to JSON for LiveReact. It's very similar to Jason.Encoder, + but it's converting structs to maps so LiveReact can diff them correctly. + + If you own the struct, you can derive the implementation specifying \ + which fields should be encoded: + + defmodule #{inspect(module)} do + @derive {LiveReact.Encoder, only: [...]} + defstruct ... + end + + If you don't own the struct you want to encode, \ + you may use Protocol.derive/3 placed outside of any module: + + Protocol.derive(LiveReact.Encoder, #{inspect(module)}, only: [...]) + Protocol.derive(LiveReact.Encoder, #{inspect(module)}) + + Nothing prevents you from defining your own implementation for the struct: + + defimpl LiveReact.Encoder, for: #{inspect(module)} do + def encode(struct, opts) do + struct + |> Map.take([:first, :second]) + |> LiveReact.Encoder.encode(opts) + end + end + """ + end + + def encode(value, _opts), do: value + + defp fields_to_encode(struct, opts) do + fields = Map.keys(struct) + + cond do + only = Keyword.get(opts, :only) -> + case only -- fields do + [] -> + only + + error_keys -> + raise ArgumentError, + ":only specified keys (#{inspect(error_keys)}) that are not defined in defstruct: " <> + "#{inspect(fields -- [:__struct__])}" + end + + except = Keyword.get(opts, :except) -> + case except -- fields do + [] -> + fields -- [:__struct__ | except] + + error_keys -> + raise ArgumentError, + ":except specified keys (#{inspect(error_keys)}) that are not defined in defstruct: " <> + "#{inspect(fields -- [:__struct__])}" + end + + true -> + fields -- [:__struct__] + end + end +end + +defimpl LiveReact.Encoder, for: Phoenix.LiveView.AsyncResult do + def encode(%Phoenix.LiveView.AsyncResult{} = struct, opts) do + LiveReact.Encoder.encode( + %{ + ok: struct.ok?, + loading: struct.loading, + failed: encode_failed(struct.failed), + result: struct.result + }, + opts + ) + end + + defp encode_failed({:error, reason}), do: reason + defp encode_failed({:exit, reason}), do: reason + defp encode_failed(other), do: other +end + +defimpl LiveReact.Encoder, for: Phoenix.LiveView.UploadConfig do + def encode(%Phoenix.LiveView.UploadConfig{} = struct, opts) do + errors = + Enum.map(struct.errors, fn {key, value} -> + %{ref: key, error: LiveReact.Encoder.encode(value, opts)} + end) + + entries = + Enum.map(struct.entries, fn entry -> + encoded = LiveReact.Encoder.encode(entry, opts) + entry_errors = errors |> Enum.filter(&(&1.ref == entry.ref)) |> Enum.map(& &1.error) + Map.put(encoded, :errors, entry_errors) + end) + + LiveReact.Encoder.encode( + %{ + ref: struct.ref, + name: struct.name, + accept: struct.accept, + max_entries: struct.max_entries, + auto_upload: struct.auto_upload?, + entries: entries, + errors: errors + }, + opts + ) + end +end + +defimpl LiveReact.Encoder, for: Phoenix.LiveView.UploadEntry do + def encode(%Phoenix.LiveView.UploadEntry{} = struct, opts) do + LiveReact.Encoder.encode( + %{ + ref: struct.ref, + client_name: struct.client_name, + client_size: struct.client_size, + client_type: struct.client_type, + progress: struct.progress, + done: struct.done?, + valid: struct.valid?, + preflighted: struct.preflighted? + }, + opts + ) + end +end + +defimpl LiveReact.Encoder, for: Phoenix.HTML.Form do + def encode(%Phoenix.HTML.Form{} = form, opts) do + LiveReact.Encoder.encode( + %{ + name: form.name, + values: encode_form_values(form, opts), + errors: encode_form_errors(form) || %{}, + valid: get_form_validity(form) + }, + opts + ) + end + + defp get_form_validity(%{source: %{valid?: valid}}), do: valid + defp get_form_validity(_), do: true + + if Code.ensure_loaded?(Ecto) do + @relations [:embed, :assoc] + + defp collect_changeset_values(%Ecto.Changeset{} = source, opts) do + data = + Map.new(source.types, fn {field, type} -> + {field, get_field_value(source, field, type, opts)} + end) + + result = if is_struct(source.data), do: Map.merge(source.data, data), else: data + Map.delete(result, :__meta__) + end + + defp get_field_value(source, field, {tag, %{cardinality: :one}}, opts) + when tag in @relations do + case Map.fetch(source.changes, field) do + {:ok, nil} -> + nil + + {:ok, %Ecto.Changeset{} = changeset} -> + collect_changeset_values(changeset, opts) + + :error -> + case Map.fetch!(source.data, field) do + %Ecto.Association.NotLoaded{} = not_loaded -> + if opts[:nilify_not_loaded], do: nil, else: not_loaded + + %{__meta__: _} = value -> + Map.delete(value, :__meta__) + + value -> + value + end + end + end + + defp get_field_value(source, field, {tag, %{cardinality: :many}}, opts) + when tag in @relations do + case Map.fetch(source.changes, field) do + {:ok, changesets} -> + changesets + |> Enum.filter(&(&1.params != nil)) + |> Enum.map(&collect_changeset_values(&1, opts)) + + :error -> + case Map.fetch!(source.data, field) do + %Ecto.Association.NotLoaded{} = not_loaded -> + if opts[:nilify_not_loaded], do: nil, else: not_loaded + + [%{__meta__: _} | _] = value -> + Enum.map(value, &Map.delete(&1, :__meta__)) + + value -> + value + end + end + end + + defp get_field_value(source, field, _type, _opts) do + Phoenix.HTML.FormData.Ecto.Changeset.input_value(source, %{params: source.params}, field) + end + + def encode_form_values(%{impl: Phoenix.HTML.FormData.Ecto.Changeset, source: source}, opts) do + source |> collect_changeset_values(opts) |> LiveReact.Encoder.encode(opts) + end + end + + def encode_form_values(form, opts) do + base_values = + form.hidden + |> Map.new() + |> Map.merge(form.data) + |> Map.merge(Map.new(form.params)) + + LiveReact.Encoder.encode(base_values, opts) + end + + if Code.ensure_loaded?(Ecto) do + defp collect_changeset_errors(%Ecto.Changeset{} = changeset) do + errors = translate_errors(changeset.errors) + + Enum.reduce(changeset.changes, errors, fn {field, value}, acc -> + case Map.get(changeset.types, field) do + {tag, %{cardinality: :one}} when tag in @relations -> + embed_errors = collect_changeset_errors(value) + if embed_errors == %{}, do: acc, else: Map.put(acc, field, embed_errors) + + {tag, %{cardinality: :many}} when tag in @relations -> + list_errors = + value + |> Enum.filter(&(&1.params != nil)) + |> Enum.map(fn embed_changeset -> + embed_errors = collect_changeset_errors(embed_changeset) + if embed_errors == %{}, do: nil, else: embed_errors + end) + + if Enum.all?(list_errors, &is_nil/1), do: acc, else: Map.put(acc, field, list_errors) + + _ -> + acc + end + end) + end + + def encode_form_errors(%{impl: Phoenix.HTML.FormData.Ecto.Changeset} = form) do + collect_changeset_errors(form.source) + end + end + + def encode_form_errors(form) do + translate_errors(form.errors) + end + + defp translate_errors(errors) do + Map.new(errors, fn {field, error} -> + {field, error |> List.wrap() |> Enum.map(&translate_error/1)} + end) + end + + defp translate_error({msg, opts}) do + Enum.reduce(opts, msg, fn {key, value}, acc -> + String.replace( + acc, + "%{#{key}}", + value + |> List.wrap() + |> Enum.map_join(", ", fn + v when is_binary(v) or is_atom(v) or is_number(v) -> to_string(v) + v -> inspect(v) + end) + ) + end) + end +end diff --git a/lib/live_react/patch.ex b/lib/live_react/patch.ex new file mode 100644 index 00000000..dd4d3e82 --- /dev/null +++ b/lib/live_react/patch.ex @@ -0,0 +1,228 @@ +defmodule LiveReact.Patch do + @moduledoc """ + Encodes LiveReact patch operations into the compact wire format used by + `data-props-diff` and `data-streams-diff`. + + The payload is a concatenated sequence of operations. Dynamic text fields are + JavaScript-string-length-prefixed, so paths and values can contain delimiters + without extra escaping. + + Operation codes: + + | Code | Operation | + | --- | --- | + | `a` | `add` | + | `d` | `remove` | + | `r` | `replace` | + | `u` | `upsert` | + | `l` | `limit` | + | `n` | nonce marker, ignored while decoding | + + Normal operations use: + + ```text + : + ``` + + `remove` omits ``. The nonce marker uses `n` and exists only + to force LiveView to send a changed attribute. + + Value tags: + + | Tag | Value | + | --- | --- | + | `z` | `nil` | + | `b0`, `b1` | booleans | + | `n:` | number | + | `s:` | string | + | `J:` | maps, lists, and complex values | + + Paths are transported as JSON Pointer strings unchanged. + """ + + @doc """ + Serializes patch maps into a compact binary payload. + + Expected patch shapes are `%{op: op, path: path, value: value}`, + `%{op: "remove", path: path}`, and `%{op: "test", path: "", value: nonce}`. + The nonce test operation is encoded as a marker and is not returned by + `deserialize/1`. + """ + def serialize(patches) do + :erlang.iolist_to_binary(for patch <- patches, do: serialize_op(patch)) + end + + @doc """ + Encodes a JSON value for safe, compact HTML attribute transport. + + The value is encoded with Jason's default JSON escaping, then JSON quote + characters are replaced with `^`. Literal `~` and `^` characters are escaped + as `~~` and `~^`, so the transform is reversible by `decode_object/1`. + """ + def encode_object(value) do + value + |> Jason.encode!() + |> String.replace("~", "~~") + |> String.replace("^", "~^") + |> String.replace("\"", "^") + end + + @doc false + def decode_object(value) when is_binary(value) do + value + |> String.replace(~r/~~|~\^|\^/, fn + "~~" -> "~" + "~^" -> "^" + "^" -> "\"" + end) + |> Jason.decode!() + end + + @doc """ + Deserializes a compact patch payload into list-shaped operations. + + Returns `[]` for an empty payload. Decoded operations are shaped as + `[op, path]` for `remove` and `[op, path, value]` for all value-bearing + operations. Nonce markers are skipped. + """ + def deserialize(""), do: [] + + def deserialize(payload) when is_binary(payload) do + payload + |> parse_ops([]) + |> Enum.reverse() + end + + defp serialize_op(%{op: "test", path: "", value: nonce}), do: ["n", to_string(nonce)] + + defp serialize_op(%{op: op, path: path, value: value}) do + path = encode_path(path) + [op_code(op), Integer.to_string(js_string_length(path)), ?:, path, encode_value(value)] + end + + defp serialize_op(%{op: op, path: path}) do + path = encode_path(path) + [op_code(op), Integer.to_string(js_string_length(path)), ?:, path] + end + + defp encode_path(path), do: path + + defp encode_value(nil), do: "z" + defp encode_value(true), do: "b1" + defp encode_value(false), do: "b0" + + defp encode_value(value) when is_number(value) do + encoded = to_string(value) + ["n", Integer.to_string(js_string_length(encoded)), ?:, encoded] + end + + defp encode_value(value) when is_binary(value), + do: ["s", Integer.to_string(js_string_length(value)), ?:, value] + + defp encode_value(value) do + encoded = encode_object(value) + ["J", Integer.to_string(js_string_length(encoded)), ?:, encoded] + end + + defp parse_ops("", acc), do: acc + + defp parse_ops("n" <> rest, acc) do + {_nonce, rest} = take_digits(rest) + parse_ops(rest, acc) + end + + defp parse_ops(<>, acc) do + {path_length, rest} = take_length(rest) + {path, rest} = take_js_string(rest, path_length) + op = op_from_code(code) + parse_op(op, path, rest, acc) + end + + defp parse_op("remove", path, rest, acc), do: parse_ops(rest, [["remove", path] | acc]) + + defp parse_op(op, path, rest, acc) do + {value, rest} = parse_value(rest) + parse_ops(rest, [[op, path, value] | acc]) + end + + defp parse_value("z" <> rest), do: {nil, rest} + defp parse_value("b1" <> rest), do: {true, rest} + defp parse_value("b0" <> rest), do: {false, rest} + + defp parse_value(<>) when tag in ["n", "s", "J"] do + {length, rest} = take_length(rest) + {encoded, rest} = take_js_string(rest, length) + + value = + case tag do + "n" -> parse_number(encoded) + "s" -> encoded + "J" -> decode_object(encoded) + end + + {value, rest} + end + + defp parse_number(value) do + case Integer.parse(value) do + {integer, ""} -> + integer + + _ -> + {float, ""} = Float.parse(value) + float + end + end + + defp take_length(payload) do + {digits, ":" <> rest} = take_digits(payload) + {String.to_integer(digits), rest} + end + + defp take_digits(payload), do: take_digits(payload, "") + + defp take_digits(<>, acc) when char in ?0..?9 do + take_digits(rest, <>) + end + + defp take_digits(rest, acc), do: {acc, rest} + + defp take_js_string(payload, length), do: take_js_string(payload, payload, length, 0) + + defp take_js_string(original, _rest, 0, bytes) do + <> = original + {value, rest} + end + + defp take_js_string(original, <>, remaining, bytes) do + units = js_code_units(codepoint) + if units > remaining, do: raise(ArgumentError, "Invalid LiveReact patch length prefix") + take_js_string(original, rest, remaining - units, bytes + utf8_byte_size(codepoint)) + end + + defp js_string_length(value), do: js_string_length(value, 0) + defp js_string_length(<<>>, acc), do: acc + + defp js_string_length(<>, acc), + do: js_string_length(rest, acc + js_code_units(codepoint)) + + defp js_code_units(codepoint) when codepoint > 0xFFFF, do: 2 + defp js_code_units(_codepoint), do: 1 + + defp utf8_byte_size(codepoint) when codepoint <= 0x7F, do: 1 + defp utf8_byte_size(codepoint) when codepoint <= 0x7FF, do: 2 + defp utf8_byte_size(codepoint) when codepoint <= 0xFFFF, do: 3 + defp utf8_byte_size(_codepoint), do: 4 + + defp op_code("add"), do: "a" + defp op_code("remove"), do: "d" + defp op_code("replace"), do: "r" + defp op_code("upsert"), do: "u" + defp op_code("limit"), do: "l" + + defp op_from_code("a"), do: "add" + defp op_from_code("d"), do: "remove" + defp op_from_code("r"), do: "replace" + defp op_from_code("u"), do: "upsert" + defp op_from_code("l"), do: "limit" +end diff --git a/lib/live_react/test.ex b/lib/live_react/test.ex index 8c35fefd..409ded85 100644 --- a/lib/live_react/test.ex +++ b/lib/live_react/test.ex @@ -38,6 +38,24 @@ defmodule LiveReact.Test do # SSR status and styling assert react.ssr == true assert react.class == "my-custom-class" + + ## Configuration + + ### enable_props_diff + + When set to `false` in your config, LiveReact will always send full props and not send diffs. + This is useful for testing scenarios where you need to inspect the complete props state + rather than just the changes. + + ```elixir + # config/test.exs + config :live_react, + enable_props_diff: false + ``` + + When disabled, the `props` field returned by `get_react/2` will always contain + the complete props state, making it easier to write comprehensive tests that verify the + full component state rather than just the incremental changes. """ @compile {:no_warn_undefined, Floki} @@ -88,12 +106,15 @@ defmodule LiveReact.Test do |> find_component!(opts) %{ - props: Jason.decode!(attr(react, "data-props")), + props: LiveReact.Patch.decode_object(attr(react, "data-props")), component: attr(react, "data-name"), id: attr(react, "id"), slots: extract_base64_slots(attr(react, "data-slots")), ssr: if(is_nil(attr(react, "data-ssr")), do: false, else: true), - class: attr(react, "class") + use_diff: attr(react, "data-use-diff") == "true", + class: attr(react, "class"), + props_diff: LiveReact.Patch.deserialize(attr(react, "data-props-diff") || ""), + streams_diff: LiveReact.Patch.deserialize(attr(react, "data-streams-diff") || "") } else raise "Floki is not installed. Add {:floki, \">= 0.30.0\", only: :test} to your dependencies to use LiveReact.Test" diff --git a/live_react_examples/assets/package-lock.json b/live_react_examples/assets/package-lock.json index 8699f532..70d5f979 100644 --- a/live_react_examples/assets/package-lock.json +++ b/live_react_examples/assets/package-lock.json @@ -11,7 +11,7 @@ "clsx": "^2.1.1", "framer-motion": "^11.2.11", "highlight.js": "^11.10.0", - "live_react": "file:../..", + "live_react": "file:../deps/live_react", "phoenix": "file:../deps/phoenix", "phoenix_html": "file:../deps/phoenix_html", "phoenix_live_view": "file:../deps/phoenix_live_view", @@ -38,20 +38,35 @@ "version": "0.1.0", "license": "MIT", "devDependencies": { - "prettier": "^3.3.2" + "jsdom": "^26.1.0", + "prettier": "^3.3.2", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "vitest": "^3.2.4" + } + }, + "../deps/live_react": { + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "jsdom": "^26.1.0", + "prettier": "^3.3.2", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "vitest": "^3.2.4" } }, "../deps/phoenix": { - "version": "1.8.7", + "version": "1.8.9", "license": "MIT", "devDependencies": { - "@babel/cli": "7.28.6", - "@babel/core": "7.29.0", - "@babel/preset-env": "7.29.3", + "@babel/cli": "7.29.7", + "@babel/core": "7.29.7", + "@babel/preset-env": "7.29.7", "@eslint/js": "^10.0.1", "@stylistic/eslint-plugin": "^5.0.0", "documentation": "^14.0.3", - "eslint": "10.2.1", + "eslint": "10.4.1", "eslint-plugin-jest": "29.15.2", "jest": "^30.0.0", "jest-environment-jsdom": "^30.0.0", @@ -63,18 +78,18 @@ "version": "4.3.0" }, "../deps/phoenix_live_view": { - "version": "1.1.31", + "version": "1.2.7", "license": "MIT", "dependencies": { "morphdom": "2.7.8" }, "devDependencies": { "@babel/cli": "7.27.2", - "@babel/core": "7.27.4", + "@babel/core": "7.29.6", "@babel/preset-env": "7.27.2", "@babel/preset-typescript": "^7.27.1", "@eslint/js": "^9.29.0", - "@playwright/test": "^1.56.1", + "@playwright/test": "^1.60.0", "@types/jest": "^30.0.0", "@types/phoenix": "^1.6.6", "css.escape": "^1.5.1", @@ -89,6 +104,8 @@ "phoenix": "1.7.21", "prettier": "3.5.3", "ts-jest": "^29.4.0", + "typedoc": "^0.28.19", + "typedoc-plugin-missing-exports": "^4.1.3", "typescript": "^5.8.3", "typescript-eslint": "^8.34.0" } @@ -2613,7 +2630,7 @@ } }, "node_modules/live_react": { - "resolved": "../..", + "resolved": "../deps/live_react", "link": true }, "node_modules/lowlight": { diff --git a/live_react_examples/assets/react-components/index.jsx b/live_react_examples/assets/react-components/index.jsx index b2ec8792..b8be8b57 100644 --- a/live_react_examples/assets/react-components/index.jsx +++ b/live_react_examples/assets/react-components/index.jsx @@ -14,6 +14,7 @@ import { SSR } from "./ssr"; import { Simple } from "./simple"; import { SimpleProps } from "./simple-props"; import { Slot } from "./slot"; +import { StreamDemo } from "./stream-demo"; import { Typescript } from "./typescript"; export default { @@ -30,5 +31,6 @@ export default { Simple, SimpleProps, Slot, + StreamDemo, Typescript, }; diff --git a/live_react_examples/assets/react-components/stream-demo.jsx b/live_react_examples/assets/react-components/stream-demo.jsx new file mode 100644 index 00000000..9b0ccb61 --- /dev/null +++ b/live_react_examples/assets/react-components/stream-demo.jsx @@ -0,0 +1,40 @@ +export function StreamDemo({ messages = [], pushEvent }) { + return ( +
+ + +
    + {messages.map((message) => ( +
  • + {message.text} + + + + +
  • + ))} +
+
+ ); +} diff --git a/live_react_examples/assets/vite.config.js b/live_react_examples/assets/vite.config.js index ca44bdc4..f90d18d4 100644 --- a/live_react_examples/assets/vite.config.js +++ b/live_react_examples/assets/vite.config.js @@ -10,6 +10,12 @@ export default defineConfig(({ command }) => { const isDev = command !== "build"; return { + server: { + port: parseInt(process.env.VITE_PORT) || 4011, + host: '0.0.0.0', // listen on all network interfaces + cors: true, // enable CORS for all origins in development + strictPort: true, // fail if port is already in use + }, base: isDev ? undefined : "/assets", publicDir: "static", plugins: [react(), liveReactPlugin(), tailwindcss()], diff --git a/live_react_examples/config/dev.exs b/live_react_examples/config/dev.exs index 5126360f..dfd29768 100644 --- a/live_react_examples/config/dev.exs +++ b/live_react_examples/config/dev.exs @@ -9,7 +9,7 @@ import Config config :live_react_examples, LiveReactExamplesWeb.Endpoint, # Binding to loopback ipv4 address prevents access from other machines. # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. - http: [ip: {127, 0, 0, 1}, port: 4000], + http: [ip: {127, 0, 0, 1}, port: 4010], check_origin: false, code_reloader: true, debug_errors: true, @@ -58,7 +58,7 @@ config :live_react_examples, LiveReactExamplesWeb.Endpoint, ] config :live_react, - vite_host: "http://localhost:5173", + vite_host: "http://localhost:4011", ssr_module: LiveReact.SSR.ViteJS, ssr: true diff --git a/live_react_examples/lib/live_react_examples.ex b/live_react_examples/lib/live_react_examples.ex index dc93313e..8b52c757 100644 --- a/live_react_examples/lib/live_react_examples.ex +++ b/live_react_examples/lib/live_react_examples.ex @@ -146,6 +146,16 @@ defmodule LiveReactExamples do } end + def demo(:stream_demo) do + %{ + raw_view_url: "#{@raw_url}#{@live_views}/stream_demo.ex", + view_url: "#{@url}#{@live_views}/stream_demo.ex", + view_language: "elixir", + raw_react_url: "#{@raw_url}#{@react}/stream-demo.jsx", + react_url: "#{@url}#{@react}/stream-demo.jsx" + } + end + def demo(demo) do raise ArgumentError, "Unknown demo: #{inspect(demo)}" end diff --git a/live_react_examples/lib/live_react_examples_web/live/demo_assigns.ex b/live_react_examples/lib/live_react_examples_web/live/demo_assigns.ex index e2048e33..ef015b94 100644 --- a/live_react_examples/lib/live_react_examples_web/live/demo_assigns.ex +++ b/live_react_examples/lib/live_react_examples_web/live/demo_assigns.ex @@ -41,6 +41,9 @@ defmodule LiveReactExamplesWeb.LiveDemoAssigns do {LiveReactExamplesWeb.LiveLinkUsage, _} -> :link_usage + {LiveReactExamplesWeb.LiveStreamDemo, _} -> + :stream_demo + {_view, _live_action} -> nil end diff --git a/live_react_examples/lib/live_react_examples_web/live/stream_demo.ex b/live_react_examples/lib/live_react_examples_web/live/stream_demo.ex new file mode 100644 index 00000000..a72a4367 --- /dev/null +++ b/live_react_examples/lib/live_react_examples_web/live/stream_demo.ex @@ -0,0 +1,39 @@ +defmodule LiveReactExamplesWeb.LiveStreamDemo do + use LiveReactExamplesWeb, :live_view + + def render(assigns) do + ~H""" + <.react name="StreamDemo" messages={@streams.messages} socket={@socket} /> + """ + end + + def mount(_params, _session, socket) do + socket = + socket + |> assign(:next_id, 1) + |> stream(:messages, [%{id: 0, text: "Welcome!"}]) + + {:ok, socket} + end + + def handle_event("add", _params, socket) do + id = socket.assigns.next_id + + socket = + socket + |> assign(:next_id, id + 1) + |> stream_insert(:messages, %{id: id, text: "Message #{id}"}) + + {:noreply, socket} + end + + def handle_event("delete", %{"id" => id}, socket) do + {:noreply, stream_delete(socket, :messages, %{id: id})} + end + + def handle_event("update", %{"id" => id}, socket) do + text = "Message #{id} updated at #{Calendar.strftime(DateTime.utc_now(), "%H:%M:%S")}" + + {:noreply, stream_insert(socket, :messages, %{id: id, text: text})} + end +end diff --git a/live_react_examples/lib/live_react_examples_web/router.ex b/live_react_examples/lib/live_react_examples_web/router.ex index 10d71851..4477f990 100644 --- a/live_react_examples/lib/live_react_examples_web/router.ex +++ b/live_react_examples/lib/live_react_examples_web/router.ex @@ -32,6 +32,7 @@ defmodule LiveReactExamplesWeb.Router do live "/slot", LiveSlot live "/link-demo", LiveLinkDemo live "/link-usage", LiveLinkUsage + live "/stream-demo", LiveStreamDemo end # Other scopes may use custom stacks. diff --git a/live_react_examples/mix.exs b/live_react_examples/mix.exs index 12772f02..c50baa30 100644 --- a/live_react_examples/mix.exs +++ b/live_react_examples/mix.exs @@ -52,9 +52,9 @@ defmodule LiveReactExamples.MixProject do {:dns_cluster, "~> 0.1.1"}, {:bandit, "~> 1.5"}, # For development - # {:live_react, path: ".."} + {:live_react, path: ".."} # For deployment - {:live_react, "~> 1.1.0"} + # {:live_react, "~> 1.1.0"} ] end diff --git a/live_react_examples/mix.lock b/live_react_examples/mix.lock index 5de2007c..44d3ac16 100644 --- a/live_react_examples/mix.lock +++ b/live_react_examples/mix.lock @@ -1,23 +1,23 @@ %{ - "bandit": {:hex, :bandit, "1.11.1", "1eb33123cc3c17ae0c3447874eb83399ee530f960c39711ed240342fbd4865fa", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [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", "d4401016df9abbc6dcd325c0b78b2b193e7c7c96bb68f31e576112be025d84a5"}, + "bandit": {:hex, :bandit, "1.12.0", "6c5214daa2469644ac4ab0113b98abc24f75e348378e6a974c6343b3e5da22ef", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.5", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "45dac82dc86f45cf4a196dee9cc5a8b791d9c9469d996055f055e6ee36c66e20"}, "castore": {:hex, :castore, "1.0.19", "6903cabdfd9d1af46454126e7c8385186659dd33ecfb74a885cae52221ad6109", [:mix], [], "hexpm", "3669e6cab13f54c2df26b3e6833745d647f35b6e30d8ddd5975df0d5c842ca98"}, "dns_cluster": {:hex, :dns_cluster, "0.1.3", "0bc20a2c88ed6cc494f2964075c359f8c2d00e1bf25518a6a6c7fd277c9b0c66", [:mix], [], "hexpm", "46cb7c4a1b3e52c7ad4cbe33ca5079fbde4840dedeafca2baf77996c2da1bc33"}, "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"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, "floki": {:hex, :floki, "0.38.3", "40d291831d93f49aa360f09447cf2e2a902e33d8711e5fb22a75f3f333e9d063", [:mix], [], "hexpm", "025aa1f5f24a70cb31bfbc7011419228596f3b062d7feda617238ba4926f83cb"}, "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "88ab3a0d790e6a47404cba02800a6b25d2afae50", [tag: "v2.1.1", sparse: "optimized", depth: 1]}, - "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, + "hpax": {:hex, :hpax, "1.0.4", "777de5d433b0fbdc7c418159c8055910faa8047ffdb3d6b31098d2a46cd7685c", [:mix], [], "hexpm", "afc7cb142ebcc2d01ce7816190b98ce5dd49e799111b24249f3443d730f377ca"}, "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, - "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"}, + "jsonpatch": {:hex, :jsonpatch, "2.3.1", "49c380f458debbd2bc6e256daeab1081dc89624288f3d77ea83952229388d316", [:make, :mix], [], "hexpm", "06c3e4fff3574cc54d335041f6322fe1b72756e396dd472615ce350d3dd5e758"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "nodejs": {:hex, :nodejs, "3.1.4", "c165a0e5901966e98d7965a20375a81d84ea89cadcde6c513f7f466e7166b3d3", [: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", "ce69d966a743bdc002892358f653bf4c2074a6e8a4a46f99299d8fd8174d0195"}, - "phoenix": {:hex, :phoenix, "1.8.7", "d8d755b4ff4b449f610223dd706b4ae64155cb720d3dc09c706c079ecea189e4", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {: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", "47352f72d6ab31009ef77516b1b3a14745be97b54061fd458031b9d8294869d5"}, + "phoenix": {:hex, :phoenix, "1.8.9", "a63ed0962ed5b903b146dab0ae8eb8387fe478f8171a5e26d56a165f35996fe1", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {: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", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "3477e2dd5a4f61820341169031bdfe21275f659923bea9c5c0ea2aa1c3fcc046"}, "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [: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", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"}, - "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.31", "c45c85df509dd79c917bc530e26c71299e3920850f65ea52ab6a19ccee66875a", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [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", "2f53cc6a9e149f30449341c2775990819d97e3b22338fe719c4d30342e6f9638"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "1.2.7", "d0f20871681216598e78baccd4f66d8686fdb8007821989bab508d293a3ed9ad", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.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", "61e97938a4fcca6d6f2c836925623abf2f52a572cc8c6085e4074f3f6337e0eb"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, "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"}, - "plug": {:hex, :plug, "1.19.2", "e4950525b22c6789dfb38a3f95d47171ba159da3fc5a33be9643b43d5e8adb98", [: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", "b6fce20a56af5e60fa5dfecf3f907bb98ec981be43c79a3809a499bc3d133de0"}, + "plug": {:hex, :plug, "1.20.3", "56c480c633ec2ce10140e236e15233bf576e1d323887d7c96711bd02ab5160db", [: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", "be266aee1b8536ef6409d58cf39a3121319f0ec47cfa1b24024485aa0e76ad76"}, "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, @@ -25,7 +25,7 @@ "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"}, - "thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"}, + "thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, - "websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [: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", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"}, + "websock_adapter": {:hex, :websock_adapter, "0.6.0", "73db5ab8aaefd1a876a97ce3e6afc96562625de69ef17a4e04426e034849d0b8", [: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", "50021a85bce8f203b086705d9e0c5415e2c7eb05d319111b0428fe71f9934617"}, } diff --git a/mix.exs b/mix.exs index 2381abf3..4b1df587 100644 --- a/mix.exs +++ b/mix.exs @@ -10,6 +10,7 @@ defmodule LiveReact.MixProject do version: @version, elixir: "~> 1.16", start_permanent: Mix.env() == :prod, + consolidate_protocols: Mix.env() != :test, deps: deps(), description: "E2E reactivity for React and LiveView", package: package(), @@ -36,8 +37,11 @@ defmodule LiveReact.MixProject do defp deps do [ {:jason, "~> 1.2"}, + {:jsonpatch, "~> 2.3"}, {:nodejs, "~> 3.1", optional: true}, {:floki, ">= 0.30.0", optional: true}, + {:ecto, "~> 3.0", optional: true}, + {:phoenix_ecto, "~> 4.0", optional: true}, {:phoenix, ">= 1.7.0"}, {:phoenix_html, ">= 3.3.1"}, {:phoenix_live_view, ">= 0.18.0"}, diff --git a/mix.lock b/mix.lock index 028f530b..d141bbe3 100644 --- a/mix.lock +++ b/mix.lock @@ -1,15 +1,18 @@ %{ "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, - "credo": {:hex, :credo, "1.7.18", "5c5596bf7aedf9c8c227f13272ac499fe8eae6237bd326f2f07dfc173786f042", [: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", "a189d164685fd945809e862fe76a7420c4398fa288d76257662aecb909d6b3e5"}, + "credo": {:hex, :credo, "1.7.19", "cc52129665fc7c15143d47838fda0f9cd6dac9ceced7bf4da6f85fcbfe64b12a", [: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", "2d8bc95d5a7bb99dd2613621d4f08c6a3575c3fd4b62e6a2b48a100352a557b8"}, + "decimal": {:hex, :decimal, "3.1.1", "430d87b04011ce6cbd4fd205be758311a81f87d552d40904abd00f015935b1d0", [:mix], [], "hexpm", "c5f25f2ced74a0587d03e6023f595db8e924c9d3922c8c8ffd9edfc4498cf1f6"}, "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, + "ecto": {:hex, :ecto, "3.14.1", "7b740d87bdf45996aa0c2c2e081640906f10caa7ce5ba328fd294c7d49d0cc6f", [:mix], [{:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "24b991956796700f467d0a3ef3d303138a3ef9ddddf8b98f43758ee067b20a30"}, "ex_doc": {:hex, :ex_doc, "0.40.3", "4a972ffe64bc07dc605af487e98fc19b72a4185f55ca031b94c0552d6071c1d9", [:mix], [{:earmark_parser, "~> 1.4.44", [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", "2756e357742fecd9749b489b85d67c9ce99c465f2e75728d9e6dc8d704b973de"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, "finch": {:hex, :finch, "0.22.0", "5c48fa6f9706a78eb9036cacb67b8b996b4e66d111c543f4c29bb0f879a6806b", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b94e83c47780fc6813f746a1f1a34ee65cda42da4c5ea26a68f0acc4498e23dc"}, - "floki": {:hex, :floki, "0.38.3", "40d291831d93f49aa360f09447cf2e2a902e33d8711e5fb22a75f3f333e9d063", [:mix], [], "hexpm", "025aa1f5f24a70cb31bfbc7011419228596f3b062d7feda617238ba4926f83cb"}, + "floki": {:hex, :floki, "0.38.4", "10f98971e892aed2c2f1b3a0f928e488e3797e1c6dd3dfd98db40b14e9a78bcf", [:mix], [], "hexpm", "bdb34645eee8e79845c7edaca2d4099a52804ee4d4a3ecc683a69451f0244973"}, "git_cli": {:hex, :git_cli, "0.3.0", "a5422f9b95c99483385b976f5d43f7e8233283a47cda13533d7c16131cb14df5", [:mix], [], "hexpm", "78cb952f4c86a41f4d3511f1d3ecb28edb268e3a7df278de2faa1bd4672eaf9b"}, "git_ops": {:hex, :git_ops, "2.10.0", "225780d8dcf9ef3393d26fa8d41d6a454a71149393c040e4b708c7c0b9c2b0f1", [: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]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "acd4a542eb425a58ce54505de69be704af3d0ef23c9b8cf9a3299d88e5d098ae"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, + "jsonpatch": {:hex, :jsonpatch, "2.3.1", "49c380f458debbd2bc6e256daeab1081dc89624288f3d77ea83952229388d316", [:make, :mix], [], "hexpm", "06c3e4fff3574cc54d335041f6322fe1b72756e396dd472615ce350d3dd5e758"}, "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, "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"}, "makeup_erlang": {:hex, :makeup_erlang, "1.1.0", "835f7e60792e08824cda445639555d7bf1bbbddb1b60b306e33cb6f6db24dc74", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "1cd6780fb1dd1a03979abaed0fe82712b0625118fd5257d3ebbf73f960c73c3c"}, @@ -19,17 +22,18 @@ "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "nodejs": {:hex, :nodejs, "3.1.4", "c165a0e5901966e98d7965a20375a81d84ea89cadcde6c513f7f466e7166b3d3", [: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", "ce69d966a743bdc002892358f653bf4c2074a6e8a4a46f99299d8fd8174d0195"}, - "phoenix": {:hex, :phoenix, "1.8.7", "d8d755b4ff4b449f610223dd706b4ae64155cb720d3dc09c706c079ecea189e4", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {: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", "47352f72d6ab31009ef77516b1b3a14745be97b54061fd458031b9d8294869d5"}, + "phoenix": {:hex, :phoenix, "1.8.9", "a63ed0962ed5b903b146dab0ae8eb8387fe478f8171a5e26d56a165f35996fe1", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {: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", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "3477e2dd5a4f61820341169031bdfe21275f659923bea9c5c0ea2aa1c3fcc046"}, + "phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"}, "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, - "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.31", "c45c85df509dd79c917bc530e26c71299e3920850f65ea52ab6a19ccee66875a", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [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", "2f53cc6a9e149f30449341c2775990819d97e3b22338fe719c4d30342e6f9638"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "1.2.6", "b2b50c51993b068fe2a29eda45ca4fe67bf74f146343468485686a253c65a8da", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.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", "5dc549a78dab94e80a340c760090e9e7bab16ac47841e34f4fff5819e02cbf35"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, "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"}, - "plug": {:hex, :plug, "1.19.2", "e4950525b22c6789dfb38a3f95d47171ba159da3fc5a33be9643b43d5e8adb98", [: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", "b6fce20a56af5e60fa5dfecf3f907bb98ec981be43c79a3809a499bc3d133de0"}, + "plug": {:hex, :plug, "1.20.3", "56c480c633ec2ce10140e236e15233bf576e1d323887d7c96711bd02ab5160db", [: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", "be266aee1b8536ef6409d58cf39a3121319f0ec47cfa1b24024485aa0e76ad76"}, "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"}, "req": {:hex, :req, "0.5.18", "48e6431cb4135e8a7815e745177485369a9b4a9924d5fe68ca00eb09ceaed1ef", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.21.0 or ~> 0.22.0", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "fa03812c440a9754bf34355e0c5d4f3ed316458db62e3284b7a352ef8dc0b996"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, - "websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [: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", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"}, + "websock_adapter": {:hex, :websock_adapter, "0.6.0", "73db5ab8aaefd1a876a97ce3e6afc96562625de69ef17a4e04426e034849d0b8", [: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", "50021a85bce8f203b086705d9e0c5415e2c7eb05d319111b0428fe71f9934617"}, } diff --git a/package-lock.json b/package-lock.json index ec16761c..87720570 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,1562 @@ "version": "0.1.0", "license": "MIT", "devDependencies": { - "prettier": "^3.3.2" + "jsdom": "^26.1.0", + "prettier": "^3.3.2", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "vitest": "^3.2.4" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/nwsapi": { + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" } }, "node_modules/prettier": { @@ -26,6 +1581,566 @@ "funding": { "url": "https://github.com/prettier/prettier?sponsor=1" } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" } } } diff --git a/package.json b/package.json index 0b2b7816..2fdae911 100644 --- a/package.json +++ b/package.json @@ -24,9 +24,15 @@ "assets/js/live_react/*" ], "devDependencies": { - "prettier": "^3.3.2" + "jsdom": "^26.1.0", + "prettier": "^3.3.2", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "vitest": "^3.2.4" }, "scripts": { - "format": "npx prettier --write ." + "format": "npx prettier --write .", + "test": "vitest run", + "test:watch": "vitest --watch" } } diff --git a/test/live_react_classification_test.exs b/test/live_react_classification_test.exs new file mode 100644 index 00000000..4a49c5e0 --- /dev/null +++ b/test/live_react_classification_test.exs @@ -0,0 +1,27 @@ +defmodule LiveReactClassificationTest do + use ExUnit.Case + + import LiveReact + import Phoenix.Component + import Phoenix.LiveViewTest + + alias LiveReact.Test + alias Phoenix.LiveView.LiveStream + + def stream_component(assigns) do + ~H""" + <.react name="TestComponent" users={@users} title={@title} /> + """ + end + + test "LiveStream values are excluded from props" do + stream = LiveStream.new(:users, make_ref(), [], []) + + html = + render_component(&stream_component/1, users: stream, title: "My Page") + + react = Test.get_react(html) + + assert react.props == %{"title" => "My Page"} + end +end diff --git a/test/live_react_encoder_form_test.exs b/test/live_react_encoder_form_test.exs new file mode 100644 index 00000000..ff194713 --- /dev/null +++ b/test/live_react_encoder_form_test.exs @@ -0,0 +1,150 @@ +defmodule LiveReact.EncoderFormTest do + use ExUnit.Case + + import Ecto.Changeset + import Phoenix.Component, only: [to_form: 2] + + alias LiveReact.Encoder + alias Phoenix.HTML.FormData + + defp encode_form(source, attrs) do + module = source.__struct__ + changeset = module.changeset(source, attrs) + form = FormData.to_form(changeset, as: module.__schema__(:source)) + Encoder.encode(form) + end + + defmodule Simple do + @moduledoc false + use Ecto.Schema + + import Ecto.Changeset + + @derive {Encoder, except: [:secret]} + embedded_schema do + field(:name, :string) + field(:secret, :string) + field(:age, :integer) + field(:active, :boolean) + field(:tags, {:array, :string}) + field(:score, :float) + end + + def changeset(simple, attrs) do + simple + |> cast(attrs, [:name, :secret, :age, :active, :tags, :score]) + |> validate_required([:name]) + |> validate_number(:age, greater_than: 0) + |> validate_number(:score, greater_than_or_equal_to: 0, less_than_or_equal_to: 100) + end + end + + describe "Phoenix.HTML.Form encoding — Ecto changeset backed" do + test "encodes form with simple values" do + simple = %Simple{} + + attrs = %{ + name: "John", + secret: "hidden_value", + age: 30, + active: true, + tags: ["elixir", "phoenix"], + score: 95.5 + } + + encoded = encode_form(simple, attrs) + + assert encoded == %{ + name: "simple", + values: %{ + id: nil, + name: "John", + age: 30, + active: true, + tags: ["elixir", "phoenix"], + score: 95.5 + }, + errors: %{}, + valid: true + } + end + + test "encodes form with validation errors" do + simple = %Simple{} + attrs = %{name: nil, age: -5, score: 150} + encoded = encode_form(simple, attrs) + + assert encoded.name == "simple" + assert encoded.valid == false + + assert encoded.values == %{ + id: nil, + name: nil, + age: -5, + active: nil, + tags: nil, + score: 150 + } + + assert encoded.errors == %{ + name: ["can't be blank"], + age: ["must be greater than 0"], + score: ["must be less than or equal to 100"] + } + end + end + + describe "Phoenix.HTML.Form encoding — plain map backed (no Ecto)" do + test "encodes form backed by simple map data" do + form_data = %{ + "name" => "John Doe", + "email" => "john@example.com", + "role" => "developer", + "bio" => "Software engineer with 5 years experience", + "notifications" => true + } + + form = to_form(form_data, as: :user) + encoded = Encoder.encode(form) + + assert encoded == %{ + name: "user", + values: %{ + "name" => "John Doe", + "email" => "john@example.com", + "role" => "developer", + "bio" => "Software engineer with 5 years experience", + "notifications" => true + }, + errors: %{}, + valid: true + } + end + + test "encodes form with empty map data" do + form_data = %{ + "name" => "", + "email" => "", + "role" => "", + "bio" => "", + "notifications" => false + } + + form = to_form(form_data, as: :profile) + encoded = Encoder.encode(form) + + assert encoded == %{ + name: "profile", + values: %{ + "name" => "", + "email" => "", + "role" => "", + "bio" => "", + "notifications" => false + }, + errors: %{}, + valid: true + } + end + end +end diff --git a/test/live_react_encoder_liveview_test.exs b/test/live_react_encoder_liveview_test.exs new file mode 100644 index 00000000..620a2314 --- /dev/null +++ b/test/live_react_encoder_liveview_test.exs @@ -0,0 +1,46 @@ +defmodule LiveReact.Encoder.LiveViewTest do + use ExUnit.Case + + alias LiveReact.Encoder + alias Phoenix.LiveView.AsyncResult + alias Phoenix.LiveView.UploadConfig + + describe "AsyncResult" do + test "encodes loading state" do + result = AsyncResult.loading() + encoded = Encoder.encode(result) + + assert encoded.ok == false + assert encoded.loading == true + assert encoded.result == nil + end + + test "encodes successful state" do + result = AsyncResult.loading() |> AsyncResult.ok("value") + encoded = Encoder.encode(result) + + assert encoded.ok == true + assert encoded.result == "value" + end + + test "encodes failed state" do + result = AsyncResult.loading() |> AsyncResult.failed({:error, "boom"}) + encoded = Encoder.encode(result) + + assert encoded.ok == false + assert encoded.failed == "boom" + end + end + + describe "UploadEntry / UploadConfig" do + test "encodes an empty UploadConfig" do + config = UploadConfig.build(:avatar, "ref123", accept: :any, max_entries: 1) + encoded = Encoder.encode(config) + + assert encoded.ref + assert encoded.name == :avatar + assert encoded.entries == [] + assert encoded.errors == [] + end + end +end diff --git a/test/live_react_encoder_test.exs b/test/live_react_encoder_test.exs new file mode 100644 index 00000000..c39a1537 --- /dev/null +++ b/test/live_react_encoder_test.exs @@ -0,0 +1,122 @@ +defmodule LiveReact.EncoderTest do + use ExUnit.Case + + alias LiveReact.Encoder + + describe "primitive types" do + test "encodes integers, floats, strings, booleans, nil, atoms" do + assert Encoder.encode(42) == 42 + assert Encoder.encode(3.14) == 3.14 + assert Encoder.encode("hello") == "hello" + assert Encoder.encode(true) == true + assert Encoder.encode(false) == false + assert Encoder.encode(nil) == nil + assert Encoder.encode(:hello) == :hello + end + end + + describe "complex types" do + test "encodes lists recursively" do + assert Encoder.encode([1, [2, 3], 4]) == [1, [2, 3], 4] + assert Encoder.encode([]) == [] + end + + test "encodes maps recursively" do + nested = %{user: %{name: "John", age: 30}, items: [1, 2, 3]} + assert Encoder.encode(nested) == nested + end + end + + defmodule TestUser do + @moduledoc false + @derive Encoder + defstruct [:name, :age, :email] + end + + defmodule TestAccount do + @moduledoc false + @derive Encoder + defstruct [:user, :balance] + end + + defmodule DerivedUserOnly do + @moduledoc false + @derive {Encoder, only: [:name, :age]} + defstruct [:name, :age, :email, :password] + end + + defmodule DerivedUserExcept do + @moduledoc false + @derive {Encoder, except: [:password]} + defstruct [:name, :age, :email, :password] + end + + defmodule NotDerivedUser do + @moduledoc false + defstruct [:name, :age, :email] + end + + describe "structs" do + test "encodes structs to maps without __struct__" do + user = %TestUser{name: "John", age: 30, email: "john@example.com"} + encoded = Encoder.encode(user) + + assert encoded == %{name: "John", age: 30, email: "john@example.com"} + refute Map.has_key?(encoded, :__struct__) + end + + test "encodes nested structs" do + account = %TestAccount{ + user: %TestUser{name: "John", age: 30, email: "j@x.com"}, + balance: 1000 + } + + assert Encoder.encode(account) == %{ + user: %{name: "John", age: 30, email: "j@x.com"}, + balance: 1000 + } + end + + test "encodes structs in lists and maps" do + users = [%TestUser{name: "John", age: 30, email: "j@x.com"}] + assert Encoder.encode(users) == [%{name: "John", age: 30, email: "j@x.com"}] + + assert Encoder.encode(%{admin: %TestUser{name: "Jane", age: 25, email: "ja@x.com"}}) == + %{admin: %{name: "Jane", age: 25, email: "ja@x.com"}} + end + end + + describe "deriving functionality" do + test "derives encoder with only specified fields" do + user = %DerivedUserOnly{name: "John", age: 30, email: "j@x.com", password: "secret"} + assert Encoder.encode(user) == %{name: "John", age: 30} + end + + test "derives encoder excluding specified fields" do + user = %DerivedUserExcept{name: "John", age: 30, email: "j@x.com", password: "secret"} + assert Encoder.encode(user) == %{name: "John", age: 30, email: "j@x.com"} + end + + test "non-derived structs raise protocol error" do + struct = %NotDerivedUser{name: "John", age: 30, email: "j@x.com"} + + assert_raise Protocol.UndefinedError, + ~r/LiveReact.Encoder protocol must always be explicitly implemented/, + fn -> + Encoder.encode(struct) + end + end + end + + describe "date and time types" do + test "encodes date and time types as ISO8601 strings" do + date = ~D[2023-01-01] + naive_datetime = ~N[2023-01-01 12:00:00] + datetime = DateTime.from_naive!(naive_datetime, "Etc/UTC") + + assert Encoder.encode(date) == Date.to_iso8601(date) + assert Encoder.encode(naive_datetime) == NaiveDateTime.to_iso8601(naive_datetime) + assert Encoder.encode(datetime) == DateTime.to_iso8601(datetime) + end + end +end diff --git a/test/live_react_patch_test.exs b/test/live_react_patch_test.exs new file mode 100644 index 00000000..47b09d18 --- /dev/null +++ b/test/live_react_patch_test.exs @@ -0,0 +1,146 @@ +defmodule LiveReactPatchTest do + use ExUnit.Case + + alias LiveReact.Patch + + describe "values" do + test "round-trips nil" do + patches = [%{op: "replace", path: "/value", value: nil}] + assert serialize_deserialize(patches) == patches + end + + test "round-trips booleans" do + patches = [ + %{op: "replace", path: "/enabled", value: true}, + %{op: "replace", path: "/disabled", value: false} + ] + + assert serialize_deserialize(patches) == patches + end + + test "round-trips integers" do + patches = [%{op: "replace", path: "/count", value: 6}] + assert serialize_deserialize(patches) == patches + end + + test "round-trips floats" do + patches = [%{op: "replace", path: "/price", value: 12.5}] + assert serialize_deserialize(patches) == patches + end + + test "round-trips strings" do + patches = [%{op: "replace", path: "/title", value: "Published"}] + assert serialize_deserialize(patches) == patches + end + + test "round-trips lists" do + patches = [%{op: "replace", path: "/tags", value: ["bug", "urgent"]}] + assert serialize_deserialize(patches) == patches + end + + test "round-trips maps" do + patches = [%{op: "replace", path: "/user", value: %{"id" => 3, "name" => "Charlie"}}] + assert serialize_deserialize(patches) == patches + end + + test "round-trips caret-encoded JSON edge cases" do + patches = [ + %{ + op: "replace", + path: "/meta", + value: %{"empty" => "", "caret" => "^", "tilde" => "~", "both" => "~^"} + } + ] + + assert serialize_deserialize(patches) == patches + end + end + + describe "paths" do + test "round-trips the document root path" do + patches = [%{op: "replace", path: "", value: %{"status" => "ready"}}] + assert serialize_deserialize(patches) == patches + end + + test "round-trips nested paths" do + patches = [%{op: "replace", path: "/profile/name", value: "Ada"}] + assert serialize_deserialize(patches) == patches + end + + test "round-trips array index paths" do + patches = [%{op: "replace", path: "/items/0/name", value: "Keyboard"}] + assert serialize_deserialize(patches) == patches + end + + test "round-trips append marker paths" do + patches = [%{op: "add", path: "/items/-", value: "new"}] + assert serialize_deserialize(patches) == patches + end + + test "round-trips JSON pointer escapes in path segments" do + patches = [%{op: "replace", path: "/settings/a~1b~0c", value: "value"}] + assert serialize_deserialize(patches) == patches + end + + test "round-trips UTF-8 paths and values using JavaScript string lengths" do + patches = [ + %{op: "replace", path: "/profile/na.me", value: "zażółć"}, + %{op: "replace", path: "/emoji", value: "🚀"} + ] + + assert Patch.serialize(patches) == "r14:/profile/na.mes6:zażółćr6:/emojis2:🚀" + assert serialize_deserialize(patches) == patches + end + end + + describe "operations" do + test "round-trips an empty patch list" do + assert serialize_deserialize([]) == [] + end + + test "round-trips remove operations without a value" do + patches = [%{op: "remove", path: "/items/0"}] + assert serialize_deserialize(patches) == patches + end + + test "omits nonce test operations when deserializing" do + patches = [ + %{op: "test", path: "", value: 123}, + %{op: "replace", path: "/count", value: 6} + ] + + assert serialize_deserialize(patches) == [%{op: "replace", path: "/count", value: 6}] + end + + test "round-trips stream upsert and limit operations" do + patches = [ + %{op: "upsert", path: "/users/-", value: %{"id" => 4, "name" => "Margaret Hamilton"}}, + %{op: "limit", path: "/users", value: 10} + ] + + assert serialize_deserialize(patches) == patches + end + end + + describe "encode_object/decode_object" do + test "round-trips a plain map" do + value = %{"name" => "Ada", "count" => 3} + assert value |> Patch.encode_object() |> Patch.decode_object() == value + end + + test "escapes carets and tildes reversibly" do + value = %{"weird" => "~^both^~"} + assert value |> Patch.encode_object() |> Patch.decode_object() == value + end + end + + defp serialize_deserialize(patches) do + patches + |> Patch.serialize() + |> Patch.deserialize() + |> Enum.map(&patch_from_wire/1) + end + + defp patch_from_wire([op, path]), do: %{op: op, path: path} + defp patch_from_wire([op, path, value]), do: %{op: op, path: path, value: value} +end diff --git a/test/live_react_props_diff_test.exs b/test/live_react_props_diff_test.exs new file mode 100644 index 00000000..12b795b2 --- /dev/null +++ b/test/live_react_props_diff_test.exs @@ -0,0 +1,133 @@ +defmodule LiveReactPropsDiffTest do + use ExUnit.Case + + import Phoenix.Component + + alias LiveReact.Test + + defp render_react_assigns(assigns) do + rendered = LiveReact.react(assigns) + html = rendered |> Phoenix.HTML.html_escape() |> Phoenix.HTML.safe_to_string() + Test.get_react(html) + end + + defp assert_patches_equal(actual, expected) do + actual_sorted = actual |> decode_patch() |> Enum.sort_by(& &1["path"]) + expected_sorted = Enum.sort_by(expected, & &1["path"]) + assert actual_sorted == expected_sorted + end + + defp decode_patch(patch_list) do + patch_list + |> Enum.map(fn + [op, path] -> %{"op" => op, "path" => path} + [op, path, value] -> %{"op" => op, "path" => path, "value" => value} + end) + |> Enum.reject(&(&1["op"] == "test")) + end + + describe "props_diff functionality" do + test "initial render has empty props_diff and use_diff true" do + assigns = %{username: "John", age: 30, socket: nil, __changed__: nil} + + react = render_react_assigns(assigns) + + assert react.props == %{"username" => "John", "age" => 30} + assert react.use_diff == true + assert_patches_equal(react.props_diff, []) + end + + test "single simple prop change creates replace operation" do + assigns = %{username: "John", age: 30, __changed__: %{}} + assigns = assign(assigns, :username, "Jane") + + react = render_react_assigns(assigns) + + assert_patches_equal(react.props_diff, [ + %{"op" => "replace", "path" => "/username", "value" => "Jane"} + ]) + end + + test "complex prop changes use Jsonpatch.diff for minimal operations" do + assigns = %{user: %{name: "John", age: 30}, __changed__: %{}} + assigns = assign(assigns, :user, %{name: "Alice", age: 25}) + + react = render_react_assigns(assigns) + + assert_patches_equal(react.props_diff, [ + %{"op" => "replace", "path" => "/user/age", "value" => 25}, + %{"op" => "replace", "path" => "/user/name", "value" => "Alice"} + ]) + end + + test "unchanged props do not appear in diff" do + assigns = %{username: "John", age: 30, __changed__: %{}} + assigns = assign(assigns, :username, "Bob") + + react = render_react_assigns(assigns) + + assert_patches_equal(react.props_diff, [ + %{"op" => "replace", "path" => "/username", "value" => "Bob"} + ]) + end + + test "lists are diffed based on id field" do + assigns = %{ + items: [%{id: 1, name: "Alice"}, %{id: 2, name: "Bob"}], + __changed__: %{} + } + + assigns = assign(assigns, :items, [%{id: 1, name: "Alice"}, %{id: 2, name: "New Bob"}]) + + react = render_react_assigns(assigns) + + assert_patches_equal(react.props_diff, [ + %{"op" => "replace", "path" => "/items/1/name", "value" => "New Bob"} + ]) + end + + test "it's possible to disable diffs per-instance" do + assigns = %{user: %{name: "John", age: 30}, diff: false, __changed__: %{}} + assigns = assign(assigns, :user, %{name: "Jane", age: 25}) + + react = render_react_assigns(assigns) + + assert react.use_diff == false + assert react.props == %{"user" => %{"name" => "Jane", "age" => 25}} + assert_patches_equal(react.props_diff, []) + end + + defmodule User do + @moduledoc false + @derive LiveReact.Encoder + defstruct [:name, :age] + end + + test "for structs uses LiveReact.Encoder to convert to map" do + assigns = %{user: %User{name: "John", age: 30}, __changed__: %{}} + assigns = assign(assigns, :user, %User{name: "Alice", age: 25}) + + react = render_react_assigns(assigns) + + assert_patches_equal(react.props_diff, [ + %{"op" => "replace", "path" => "/user/age", "value" => 25}, + %{"op" => "replace", "path" => "/user/name", "value" => "Alice"} + ]) + end + + test "struct props without LiveReact.Encoder raise a helpful error" do + defmodule Undecoded do + @moduledoc false + defstruct [:name] + end + + assigns = %{user: struct!(Undecoded, name: "John"), __changed__: nil} + + assert_raise Protocol.UndefinedError, + ~r/LiveReact.Encoder protocol must always be explicitly implemented/, + fn -> + render_react_assigns(assigns) + end + end + end +end diff --git a/test/live_react_streams_diff_test.exs b/test/live_react_streams_diff_test.exs new file mode 100644 index 00000000..a72d5ff0 --- /dev/null +++ b/test/live_react_streams_diff_test.exs @@ -0,0 +1,170 @@ +defmodule LiveReactStreamsDiffTest do + use ExUnit.Case + + alias LiveReact.Test + alias Phoenix.LiveView.LiveStream + alias Phoenix.LiveView.Socket + + defp render_react_assigns(assigns) do + rendered = LiveReact.react(assigns) + html = rendered |> Phoenix.HTML.html_escape() |> Phoenix.HTML.safe_to_string() + Test.get_react(html) + end + + # `react/1` forces a full stream reset whenever the render is "dead" (no connected + # socket), since the client has no prior state to patch against in that case. These + # tests exercise incremental (non-initial) diffs, so they simulate a connected + # LiveView socket to opt into the incremental code path instead of the dead/init one. + defp connected_socket, do: %Socket{transport_pid: self()} + + defp assert_patches_equal(actual, expected) do + actual_sorted = actual |> decode_patch() |> Enum.sort_by(&{&1["path"], &1["op"]}) + expected_sorted = Enum.sort_by(expected, &{&1["path"], &1["op"]}) + assert actual_sorted == expected_sorted + end + + defp decode_patch(patch_list) do + patch_list + |> Enum.map(fn + [op, path] -> %{"op" => op, "path" => path} + [op, path, value] -> %{"op" => op, "path" => path, "value" => value} + end) + |> Enum.reject(&(&1["op"] == "test")) + end + + defmodule StreamUser do + @moduledoc false + @derive LiveReact.Encoder + defstruct [:id, :name, :age] + end + + describe "LiveStream diff functionality" do + test "initial render with LiveStream has stream diff in streams_diff" do + users = [ + %StreamUser{id: 1, name: "Alice", age: 30}, + %StreamUser{id: 2, name: "Bob", age: 25} + ] + + stream = LiveStream.new(:users, make_ref(), users, []) + + react = render_react_assigns(%{users: stream, __changed__: nil}) + + expected_patches = [ + %{"op" => "replace", "path" => "/users", "value" => []}, + %{ + "op" => "upsert", + "path" => "/users/-", + "value" => %{"__dom_id" => "users-1", "age" => 30, "id" => 1, "name" => "Alice"} + }, + %{ + "op" => "upsert", + "path" => "/users/-", + "value" => %{"__dom_id" => "users-2", "age" => 25, "id" => 2, "name" => "Bob"} + } + ] + + assert react.props == %{} + assert_patches_equal(react.streams_diff, expected_patches) + end + + test "inserting item to LiveStream creates upsert operation" do + new_user = %StreamUser{id: 3, name: "Charlie", age: 28} + stream = LiveStream.new(:users, make_ref(), [], []) + stream = LiveStream.insert_item(stream, new_user, -1, nil, false) + + react = + render_react_assigns(%{ + users: stream, + socket: connected_socket(), + __changed__: %{users: LiveStream.new(:users, make_ref(), [], [])} + }) + + assert_patches_equal(react.streams_diff, [ + %{ + "op" => "upsert", + "path" => "/users/-", + "value" => %{"id" => 3, "name" => "Charlie", "age" => 28, "__dom_id" => "users-3"} + } + ]) + end + + test "deleting item from LiveStream creates remove operation" do + user_to_delete = %StreamUser{id: 2, name: "Bob", age: 25} + stream = LiveStream.new(:users, make_ref(), [], []) + stream = LiveStream.delete_item(stream, user_to_delete) + + react = + render_react_assigns(%{ + users: stream, + socket: connected_socket(), + __changed__: %{users: LiveStream.new(:users, make_ref(), [], [])} + }) + + assert_patches_equal(react.streams_diff, [%{"op" => "remove", "path" => "/users/$$users-2"}]) + end + + test "resetting LiveStream creates replace operation" do + stream = LiveStream.new(:users, make_ref(), [], []) + stream = LiveStream.reset(stream) + + react = + render_react_assigns(%{ + users: stream, + socket: connected_socket(), + __changed__: %{users: LiveStream.new(:users, make_ref(), [], [])} + }) + + assert_patches_equal(react.streams_diff, [ + %{"op" => "replace", "path" => "/users", "value" => []} + ]) + end + + test "stream with limit adds limit operation" do + stream = LiveStream.new(:users, make_ref(), [], []) + + stream = + LiveStream.insert_item(stream, %StreamUser{id: 1, name: "User", age: 1}, -1, 5, false) + + react = + render_react_assigns(%{ + users: stream, + socket: connected_socket(), + __changed__: %{users: LiveStream.new(:users, make_ref(), [], [])} + }) + + decoded = decode_patch(react.streams_diff) + limit_op = Enum.find(decoded, &(&1["op"] == "limit")) + + assert limit_op == %{"op" => "limit", "path" => "/users", "value" => 5} + end + + test "stream insert with update_only flag creates replace operation" do + stream = LiveStream.new(:users, make_ref(), [], []) + + stream = + LiveStream.insert_item(stream, %StreamUser{id: 1, name: "Updated", age: 1}, -1, nil, true) + + react = + render_react_assigns(%{ + users: stream, + socket: connected_socket(), + __changed__: %{users: LiveStream.new(:users, make_ref(), [], [])} + }) + + decoded = decode_patch(react.streams_diff) + + replace_op = + Enum.find(decoded, &(&1["op"] == "replace" && String.contains?(&1["path"], "$$"))) + + assert replace_op["value"]["name"] == "Updated" + end + + test "LiveStream assigns do not appear in props" do + stream = LiveStream.new(:users, make_ref(), [], []) + + react = render_react_assigns(%{users: stream, title: "Page", __changed__: nil}) + + assert react.props == %{"title" => "Page"} + end + end +end diff --git a/test/live_react_test_helper_test.exs b/test/live_react_test_helper_test.exs new file mode 100644 index 00000000..3049bfb9 --- /dev/null +++ b/test/live_react_test_helper_test.exs @@ -0,0 +1,25 @@ +defmodule LiveReactTestHelperTest do + use ExUnit.Case + + import LiveReact + import Phoenix.Component + import Phoenix.LiveViewTest + + alias LiveReact.Test + + def component(assigns) do + ~H""" + <.react name="TestComponent" title="Hello" /> + """ + end + + test "get_react exposes props_diff, streams_diff, and use_diff" do + html = render_component(&component/1) + react = Test.get_react(html) + + assert react.props == %{"title" => "Hello"} + assert react.use_diff == true + assert react.props_diff == [] + assert react.streams_diff == [] + end +end diff --git a/vitest.config.js b/vitest.config.js new file mode 100644 index 00000000..64cafc49 --- /dev/null +++ b/vitest.config.js @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "jsdom", + globals: true, + include: ["assets/**/*.test.js"], + }, +});