From 9873f0b1f7d192bcb0b1b0bdb67c1758012c0fe6 Mon Sep 17 00:00:00 2001 From: Farhan Date: Tue, 22 Sep 2026 20:16:16 +0000 Subject: [PATCH 1/5] perf: structurally share unchanged values in applyDelta Each delta from the backend is freshly parsed JSON, so even unchanged fields arrived with new identities and defeated every identity-based memo comparison. applyDelta now merges each delta value into the previous one, keeping the old plain object or array wherever the contents are deep-equal, and returns the same state object when a delta changes nothing so useReducer bails out of the render. Only plain objects and arrays are shared; everything else is replaced as before. New containers are allocated only once a difference is found. Claude-Session: https://claude.ai/code/session_01TqoG8S2SYfnDSTBzXNRf7C --- ...ly-delta-structural-sharing.performance.md | 1 + .../reflex_base/.templates/web/utils/state.js | 97 ++++++++++++++++++- .../units/compiler/test_state_js_template.py | 83 ++++++++++++++++ 3 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 packages/reflex-base/news/+apply-delta-structural-sharing.performance.md diff --git a/packages/reflex-base/news/+apply-delta-structural-sharing.performance.md b/packages/reflex-base/news/+apply-delta-structural-sharing.performance.md new file mode 100644 index 00000000000..4093d008ea7 --- /dev/null +++ b/packages/reflex-base/news/+apply-delta-structural-sharing.performance.md @@ -0,0 +1 @@ +Unchanged objects and arrays in state updates now keep their previous identity on the frontend, so memoized components skip re-rendering them, and an update that changes nothing no longer re-renders at all. diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js index 1b362f7b339..e9b29db63a7 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js @@ -152,13 +152,108 @@ export const isStateful = () => { return event_queue.some((event) => event.name.startsWith("reflex___state")); }; +/** + * Whether a value is a plain object (not an array, Date or class instance). + * @param value The value to check. + * @returns True if the value is a plain object. + */ +const isPlainObject = (value) => { + if (typeof value !== "object" || value === null) { + return false; + } + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +}; + +/** + * Structurally share the parts of next that deep-equal prev. + * + * Plain objects and arrays whose contents are unchanged keep the identity of + * the previous value, so memoized components and caches keyed on identity + * still hit. Any other value is taken from next as is. + * @param prev The previous value. + * @param next The new value. + * @returns prev if deep-equal to next, otherwise next with unchanged parts shared from prev. + */ +const replaceEqualDeep = (prev, next) => { + if (prev === next) { + return prev; + } + if (Array.isArray(prev) && Array.isArray(next)) { + // Allocate only once an element differs; until then prev is the result. + let out; + for (let i = 0; i < next.length; i++) { + const value = replaceEqualDeep(prev[i], next[i]); + if (out === undefined && value !== prev[i]) { + out = prev.slice(0, i); + } + out?.push(value); + } + if (out !== undefined) { + return out; + } + return prev.length === next.length ? prev : prev.slice(0, next.length); + } + if (isPlainObject(prev) && isPlainObject(next)) { + const keys = Object.keys(next); + let out; + // Spreading next keeps a JSON "__proto__" key an own data property, so the + // assignments below never hit the prototype setter. + const copyPrefix = (end) => { + out = { ...next }; + for (let j = 0; j < end; j++) { + out[keys[j]] = prev[keys[j]]; + } + }; + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + if (!Object.hasOwn(prev, key)) { + if (out === undefined) { + copyPrefix(i); + } + continue; + } + const value = replaceEqualDeep(prev[key], next[key]); + if (out === undefined && value !== prev[key]) { + copyPrefix(i); + } + if (out !== undefined) { + out[key] = value; + } + } + if (out !== undefined) { + return out; + } + if (Object.keys(prev).length === keys.length) { + return prev; + } + // Keys were removed; every remaining value is unchanged. + copyPrefix(keys.length); + return out; + } + return next; +}; + /** * Apply a delta to the state. + * + * Unchanged values keep their previous identity, and a delta that changes + * nothing returns the same state object so useReducer skips the render. * @param state The state to apply the delta to. * @param delta The delta to apply. + * @returns The new state, or state itself if nothing changed. */ export const applyDelta = (state, delta) => { - return { ...state, ...delta }; + let out; + for (const key in delta) { + const own = Object.hasOwn(state, key); + const value = own ? replaceEqualDeep(state[key], delta[key]) : delta[key]; + if (!own || value !== state[key]) { + out ??= { ...state }; + out[key] = value; + } + } + return out ?? state; }; /** diff --git a/tests/units/compiler/test_state_js_template.py b/tests/units/compiler/test_state_js_template.py index bf8898c57bf..a26d42c6c9c 100644 --- a/tests/units/compiler/test_state_js_template.py +++ b/tests/units/compiler/test_state_js_template.py @@ -121,3 +121,86 @@ def test_merge_slot_props_handles_conditional_event_handlers() -> None: capture_output=True, text=True, ) + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node missing") +def test_apply_delta_shares_unchanged_values() -> None: + """Deltas keep the identity of unchanged plain objects and arrays.""" + content = STATE_JS_TEMPLATE.read_text() + helpers = content[ + content.index("const isPlainObject =") : content.index( + "export const evalReactComponent =" + ) + ] + subprocess.run( + [ + "node", + "--input-type=module", + "--eval", + helpers + + """ +import assert from 'node:assert/strict'; +const parse = (v) => JSON.parse(JSON.stringify(v)); +const rows = [{id: 1, tags: ['a']}, {id: 2, tags: ['b']}, {id: 3, tags: []}]; +const state = {rows, kpi: {a: 1, b: {c: 2}}, n: 1, s: 'x', none: null}; + +// A delta equal to the current state returns the same state object. +assert.equal(applyDelta(state, parse(state)), state); +assert.equal(applyDelta(state, {}), state); + +// One changed row: siblings keep their identity, the changed row does not. +const delta = parse({rows, kpi: state.kpi}); +delta.rows[1].tags = ['c']; +const next = applyDelta(state, delta); +assert.notEqual(next, state); +assert.equal(next.kpi, state.kpi); +assert.notEqual(next.rows, rows); +assert.equal(next.rows[0], rows[0]); +assert.equal(next.rows[2], rows[2]); +assert.notEqual(next.rows[1], rows[1]); +assert.deepEqual(next.rows[1], {id: 2, tags: ['c']}); +assert.deepEqual(rows[1], {id: 2, tags: ['b']}); +assert.equal(next.n, 1); + +// Length changes share the common prefix without mutating the old array. +const grown = applyDelta(state, {rows: parse([...rows, {id: 4, tags: []}])}).rows; +assert.equal(grown.length, 4); +assert.equal(grown[2], rows[2]); +const shrunk = applyDelta(state, {rows: parse(rows.slice(0, 2))}).rows; +assert.deepEqual(shrunk, rows.slice(0, 2)); +assert.equal(shrunk[1], rows[1]); +assert.equal(rows.length, 3); + +// Added, removed and renamed keys produce a new object with shared values. +const added = applyDelta(state, {kpi: parse({...state.kpi, d: 3})}).kpi; +assert.deepEqual(added, {a: 1, b: {c: 2}, d: 3}); +assert.equal(added.b, state.kpi.b); +const removed = applyDelta(state, {kpi: parse({b: state.kpi.b})}).kpi; +assert.deepEqual(removed, {b: {c: 2}}); +assert.equal(removed.b, state.kpi.b); +const undef = {kpi: {a: undefined}}; +assert.deepEqual(applyDelta(undef, {kpi: {b: undefined}}).kpi, {b: undefined}); + +// Type changes and non-plain values are replaced as before. +assert.deepEqual(applyDelta(state, {kpi: [1]}).kpi, [1]); +assert.equal(applyDelta(state, {none: {}}).none.constructor, Object); +const date = new Date(0); +const withDate = {d: new Date(0)}; +assert.equal(applyDelta(withDate, {d: date}).d, date); + +// A "__proto__" key from JSON stays an own data property. +const proto = applyDelta({o: {}}, {o: JSON.parse('{"__proto__": {"x": 1}}')}).o; +assert.equal(Object.getPrototypeOf(proto), Object.prototype); +assert.deepEqual(Object.getOwnPropertyDescriptor(proto, '__proto__').value, {x: 1}); +const protoState = {o: JSON.parse('{"__proto__": {"x": 1}, "y": [1]}')}; +const protoNext = applyDelta(protoState, {o: JSON.parse('{"__proto__": {"x": 1}, "y": [2]}')}).o; +assert.equal(Object.getPrototypeOf(protoNext), Object.prototype); +assert.equal(Object.getOwnPropertyDescriptor(protoNext, '__proto__').value, + Object.getOwnPropertyDescriptor(protoState.o, '__proto__').value); +assert.deepEqual(protoNext.y, [2]); +""", + ], + check=True, + capture_output=True, + text=True, + ) From 5bdf6245e87d5d4cdc885dee9ec6d185629eb412 Mon Sep 17 00:00:00 2001 From: Farhan Date: Tue, 22 Sep 2026 20:17:52 +0000 Subject: [PATCH 2/5] chore: name news fragment after PR number Claude-Session: https://claude.ai/code/session_01TqoG8S2SYfnDSTBzXNRf7C --- ...elta-structural-sharing.performance.md => 7273.performance.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/reflex-base/news/{+apply-delta-structural-sharing.performance.md => 7273.performance.md} (100%) diff --git a/packages/reflex-base/news/+apply-delta-structural-sharing.performance.md b/packages/reflex-base/news/7273.performance.md similarity index 100% rename from packages/reflex-base/news/+apply-delta-structural-sharing.performance.md rename to packages/reflex-base/news/7273.performance.md From 751e7c2e8f69e854cfd115d52af05ebb58164226 Mon Sep 17 00:00:00 2001 From: Farhan Date: Wed, 30 Sep 2026 23:14:37 +0000 Subject: [PATCH 3/5] Compare delta values with Object.is in applyDelta Strict equality dropped a 0 -> -0 update (python sends -0.0) and treated a repeated NaN, revived by parseJson, as a change on every delta. Claude-Session: https://claude.ai/code/session_01PXcFKAgnzhgdnSvhuCmxWE --- .../src/reflex_base/.templates/web/utils/state.js | 9 +++++---- tests/units/compiler/test_state_js_template.py | 8 ++++++++ 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js index 9c402e18a57..77444dfae64 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js @@ -202,7 +202,8 @@ const isPlainObject = (value) => { * @returns prev if deep-equal to next, otherwise next with unchanged parts shared from prev. */ const replaceEqualDeep = (prev, next) => { - if (prev === next) { + // Object.is, unlike ===, tells -0 from 0 and treats NaN as equal to itself. + if (Object.is(prev, next)) { return prev; } if (Array.isArray(prev) && Array.isArray(next)) { @@ -210,7 +211,7 @@ const replaceEqualDeep = (prev, next) => { let out; for (let i = 0; i < next.length; i++) { const value = replaceEqualDeep(prev[i], next[i]); - if (out === undefined && value !== prev[i]) { + if (out === undefined && !Object.is(value, prev[i])) { out = prev.slice(0, i); } out?.push(value); @@ -240,7 +241,7 @@ const replaceEqualDeep = (prev, next) => { continue; } const value = replaceEqualDeep(prev[key], next[key]); - if (out === undefined && value !== prev[key]) { + if (out === undefined && !Object.is(value, prev[key])) { copyPrefix(i); } if (out !== undefined) { @@ -274,7 +275,7 @@ export const applyDelta = (state, delta) => { for (const key in delta) { const own = Object.hasOwn(state, key); const value = own ? replaceEqualDeep(state[key], delta[key]) : delta[key]; - if (!own || value !== state[key]) { + if (!own || !Object.is(value, state[key])) { out ??= { ...state }; out[key] = value; } diff --git a/tests/units/compiler/test_state_js_template.py b/tests/units/compiler/test_state_js_template.py index a26d42c6c9c..5ef6f825b78 100644 --- a/tests/units/compiler/test_state_js_template.py +++ b/tests/units/compiler/test_state_js_template.py @@ -181,6 +181,14 @@ def test_apply_delta_shares_unchanged_values() -> None: const undef = {kpi: {a: undefined}}; assert.deepEqual(applyDelta(undef, {kpi: {b: undefined}}).kpi, {b: undefined}); +// Values compare with Object.is: python's -0.0 replaces 0, while a repeated +// NaN (revived by parseJson) is unchanged. +const zeros = {z: 0, a: [0], o: {v: 0}}; +const negated = applyDelta(zeros, JSON.parse('{"z": -0.0, "a": [-0.0], "o": {"v": -0.0}}')); +assert.deepEqual(negated, {z: -0, a: [-0], o: {v: -0}}); +const nans = {x: NaN, a: [NaN], o: {v: NaN}}; +assert.equal(applyDelta(nans, {x: NaN, a: [NaN], o: {v: NaN}}), nans); + // Type changes and non-plain values are replaced as before. assert.deepEqual(applyDelta(state, {kpi: [1]}).kpi, [1]); assert.equal(applyDelta(state, {none: {}}).none.constructor, Object); From a7b8fa73097942176c50f02109fb0fa45e5dd835 Mon Sep 17 00:00:00 2001 From: Farhan Date: Wed, 30 Sep 2026 23:14:54 +0000 Subject: [PATCH 4/5] Keep a reordered mapping's key order in applyDelta A dict reordered with the same values reused the previous object, so the UI kept the old key order; prev is now only reused when the keys match in order. Claude-Session: https://claude.ai/code/session_01PXcFKAgnzhgdnSvhuCmxWE --- .../src/reflex_base/.templates/web/utils/state.js | 10 ++++++++-- tests/units/compiler/test_state_js_template.py | 6 ++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js index 77444dfae64..7cad3559f2a 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js @@ -251,10 +251,16 @@ const replaceEqualDeep = (prev, next) => { if (out !== undefined) { return out; } - if (Object.keys(prev).length === keys.length) { + // Mappings render in key order, so prev is only reused with the same keys + // in the same order. + const prevKeys = Object.keys(prev); + if ( + prevKeys.length === keys.length && + prevKeys.every((key, i) => key === keys[i]) + ) { return prev; } - // Keys were removed; every remaining value is unchanged. + // Keys were removed or reordered; every remaining value is unchanged. copyPrefix(keys.length); return out; } diff --git a/tests/units/compiler/test_state_js_template.py b/tests/units/compiler/test_state_js_template.py index 5ef6f825b78..c99cdc3d5d3 100644 --- a/tests/units/compiler/test_state_js_template.py +++ b/tests/units/compiler/test_state_js_template.py @@ -181,6 +181,12 @@ def test_apply_delta_shares_unchanged_values() -> None: const undef = {kpi: {a: undefined}}; assert.deepEqual(applyDelta(undef, {kpi: {b: undefined}}).kpi, {b: undefined}); +// Reordering a mapping without changing its values takes the new key order. +const ordered = {d: {a: 1, b: {c: 2}}}; +const reordered = applyDelta(ordered, {d: JSON.parse('{"b": {"c": 2}, "a": 1}')}).d; +assert.deepEqual(Object.keys(reordered), ['b', 'a']); +assert.equal(reordered.b, ordered.d.b); + // Values compare with Object.is: python's -0.0 replaces 0, while a repeated // NaN (revived by parseJson) is unchanged. const zeros = {z: 0, a: [0], o: {v: 0}}; From b8727b1f52bdb05e6939f711fcfbd777dc7fc736 Mon Sep 17 00:00:00 2001 From: Farhan Date: Wed, 30 Sep 2026 23:15:15 +0000 Subject: [PATCH 5/5] Assert the extracted state.js helpers contain applyDelta A moved marker now fails at the extraction point instead of as a node ReferenceError. Claude-Session: https://claude.ai/code/session_01PXcFKAgnzhgdnSvhuCmxWE --- tests/units/compiler/test_state_js_template.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/units/compiler/test_state_js_template.py b/tests/units/compiler/test_state_js_template.py index c99cdc3d5d3..55ff92f81d2 100644 --- a/tests/units/compiler/test_state_js_template.py +++ b/tests/units/compiler/test_state_js_template.py @@ -132,6 +132,7 @@ def test_apply_delta_shares_unchanged_values() -> None: "export const evalReactComponent =" ) ] + assert "export const applyDelta" in helpers subprocess.run( [ "node",