DEV: More efficiently trigger topic tracking state on state change (#16952)

* When loading topics in bulk, only trigger state change callbacks after
all the topics have been loaded and we determine that state has actually
changed.

* State change callbacks are also only triggered when state has changed.

The use of JSON.stringify might raise some performance concerns here as this is a
performance sensitive codepath. However, I measured the time for each
`_setState` function call locally, by wrapping the function call with
`performance.now()`, and did not see any significant overhead.
This commit is contained in:
Alan Guo Xiang Tan 2022-05-31 10:20:55 +08:00 committed by GitHub
parent b9e230e7fa
commit 30bd1dcefd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 121 additions and 17 deletions

View File

@ -423,7 +423,17 @@ const TopicTrackingState = EmberObject.extend({
// make sure all the state is up to date with what is accurate
// from the server
list.topics.forEach(this._syncStateFromListTopic);
const newStates = [];
for (const topic of list.topics) {
const newState = this._newStateFromListTopic(topic);
if (newState) {
newStates.push(newState);
}
}
this.loadStates(newStates);
// correct missing states, safeguard in case message bus is corrupt
if (this._shouldCompensateState(list, filter, queryParams)) {
@ -610,14 +620,38 @@ const TopicTrackingState = EmberObject.extend({
},
loadStates(data) {
(data || []).forEach((topic) => {
this.modifyState(topic, topic);
if (!data || data.length === 0) {
return;
}
const modified = data.every((topic) => {
return this._setState({ topic, data: topic, skipAfterStateChange: true });
});
if (modified) {
this._afterStateChange();
}
},
_setState({ topic, data, skipAfterStateChange }) {
const stateKey = this._stateKey(topic);
const oldState = this.states.get(stateKey);
if (!oldState || JSON.stringify(oldState) !== JSON.stringify(data)) {
this.states.set(stateKey, data);
if (!skipAfterStateChange) {
this._afterStateChange();
}
return true;
} else {
return false;
}
},
modifyState(topic, data) {
this.states.set(this._stateKey(topic), data);
this._afterStateChange();
this._setState({ topic, data });
},
modifyStateProp(topic, prop, data) {
@ -661,16 +695,13 @@ const TopicTrackingState = EmberObject.extend({
// topic from the list (e.g. updates category, highest read post
// number, tags etc.)
@bind
_syncStateFromListTopic(topic) {
_newStateFromListTopic(topic) {
const state = this.findState(topic.id) || {};
// make a new copy so we aren't modifying the state object directly while
// we make changes
const newState = { ...state };
newState.topic_id = topic.id;
newState.notification_level = topic.notification_level;
// see ListableTopicSerializer for unread_posts/unseen and other
// topic property logic
if (topic.unseen) {
@ -687,7 +718,16 @@ const TopicTrackingState = EmberObject.extend({
return;
}
newState.topic_id = topic.id;
if (topic.notification_level) {
newState.notification_level = topic.notification_level;
}
if (topic.highest_post_number) {
newState.highest_post_number = topic.highest_post_number;
}
if (topic.category) {
newState.category_id = topic.category.id;
}
@ -696,7 +736,7 @@ const TopicTrackingState = EmberObject.extend({
newState.tags = topic.tags;
}
this.modifyState(topic.id, newState);
return newState;
},
// this stops sync of tracking state when list is filtered, in the past this
@ -791,12 +831,12 @@ const TopicTrackingState = EmberObject.extend({
}
}
const old = this.findState(data);
const old = { ...this.findState(data) };
if (data.message_type === "latest") {
this.notifyIncoming(data);
if ((old && old.tags) !== data.payload.tags) {
if (old.tags !== data.payload.tags) {
this.modifyStateProp(data, "tags", data.payload.tags);
this.incrementMessageCount();
}
@ -814,7 +854,6 @@ const TopicTrackingState = EmberObject.extend({
// we have, and then substitute inferred values for last_read_post_number
// and notification_level. Any errors will be corrected when a
// topic-list is loaded which includes the topic.
let payload = data.payload;
if (old) {

View File

@ -23,6 +23,23 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) {
this.clock.restore();
});
test("bulk loading states only calls onStateChange callback once", function (assert) {
const trackingState = TopicTrackingState.create();
let stateCallbackCalled = 0;
trackingState.onStateChange(() => {
stateCallbackCalled += 1;
});
trackingState.loadStates([
{ topic_id: 1 },
{ topic_id: 2 },
{ topic_id: 3 },
]);
assert.strictEqual(stateCallbackCalled, 1, "callback is only called once");
});
test("tag counts", function (assert) {
const trackingState = TopicTrackingState.create();
@ -338,13 +355,54 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) {
);
});
test("sync - no changes to state", function (assert) {
const trackingState = TopicTrackingState.create();
trackingState.loadStates([
{ topic_id: 111, last_read_post_number: null },
{ topic_id: 222, last_read_post_number: null },
]);
let stateCallbackCalled = 0;
trackingState.onStateChange(() => {
stateCallbackCalled += 1;
});
const list = {
topics: [
Topic.create({
id: 111,
last_read_post_number: null,
unseen: true,
}),
Topic.create({
id: 222,
last_read_post_number: null,
unseen: true,
}),
],
};
trackingState.sync(list, "unread");
assert.strictEqual(stateCallbackCalled, 0, "callback is not called");
});
test("sync - updates state to match list topic for unseen and unread/new topics", function (assert) {
const trackingState = TopicTrackingState.create();
trackingState.loadStates([
{ topic_id: 111, last_read_post_number: 0 },
{ topic_id: 222, last_read_post_number: 1 },
]);
let stateCallbackCalled = 0;
trackingState.onStateChange(() => {
stateCallbackCalled += 1;
});
const list = {
topics: [
Topic.create({
@ -385,6 +443,8 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) {
17,
"last_read_post_number is highest_post_number - (unread + new)"
);
assert.strictEqual(stateCallbackCalled, 1, "callback is only called once");
});
test("sync - states missing from the topic list are updated based on the selected filter", function (assert) {
@ -476,11 +536,14 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) {
});
test("state is modified and callback is called", function (assert) {
let stateCallbackCalled = false;
let stateCallbackCalled = 0;
trackingState.onStateChange(() => {
stateCallbackCalled = true;
stateCallbackCalled += 1;
});
publishToMessageBus(`/unread`, unreadTopicPayload);
assert.deepEqual(
trackingState.findState(111),
{
@ -496,9 +559,10 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) {
},
"topic state updated"
);
assert.strictEqual(
stateCallbackCalled,
true,
1,
"state change callback called"
);
});
@ -597,6 +661,7 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) {
message_type: "dismiss_new",
payload: { topic_ids: [112] },
});
assert.strictEqual(trackingState.findState(112).is_seen, true);
});