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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/reflex-base/news/7273.performance.md
Original file line number Diff line number Diff line change
@@ -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.
104 changes: 103 additions & 1 deletion packages/reflex-base/src/reflex_base/.templates/web/utils/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -178,13 +178,115 @@ const normalizeEvents = (events) => {
return normalized;
};

/**
* 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) => {
// 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)) {
// 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 && !Object.is(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);
Comment thread
FarhanAliRaza marked this conversation as resolved.
}
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 && !Object.is(value, prev[key])) {
copyPrefix(i);
}
if (out !== undefined) {
out[key] = value;
Comment thread
FarhanAliRaza marked this conversation as resolved.
}
}
if (out !== undefined) {
return out;
}
// 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 or reordered; 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 || !Object.is(value, state[key])) {
out ??= { ...state };
out[key] = value;
}
}
return out ?? state;
};

/**
Expand Down
98 changes: 98 additions & 0 deletions tests/units/compiler/test_state_js_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,101 @@ 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(
Comment thread
FarhanAliRaza marked this conversation as resolved.
"export const evalReactComponent ="
)
]
assert "export const applyDelta" in helpers
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});

// 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}};
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);
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,
)
Loading