Fix syncback not removing stale $properties at engine defaults (#1244)

This commit is contained in:
Floyd Horng
2026-06-11 02:47:50 +01:00
committed by GitHub
parent ac6941f054
commit d12120bab6
7 changed files with 150 additions and 10 deletions

View File

@@ -45,6 +45,7 @@ Making a new release? Simply add the new header with the version and date undern
* Instances that share a name and class are now robustly matched on resync by comparing their properties, instead of relying on child order alone. ([#1266]) * Instances that share a name and class are now robustly matched on resync by comparing their properties, instead of relying on child order alone. ([#1266])
* Rojo now reports a clear error instead of panicking in several cases, including when the `serve` port is already in use, when a synced file is read-only or locked, when the filesystem watcher can't be created, and when the working directory is inaccessible. ([#1267]) * Rojo now reports a clear error instead of panicking in several cases, including when the `serve` port is already in use, when a synced file is read-only or locked, when the filesystem watcher can't be created, and when the working directory is inaccessible. ([#1267])
* `rojo serve` now validates the `Host`/`Origin` headers to protect the local/private server against DNS rebinding, gates `/api/open` to local clients, and warns when bound to a network-reachable address. The accepted hosts can be extended with the `--allowed-hosts` option or a project's `serveAllowedHosts` field, for example to reach a network-exposed server by hostname. ([#1270]) * `rojo serve` now validates the `Host`/`Origin` headers to protect the local/private server against DNS rebinding, gates `/api/open` to local clients, and warns when bound to a network-reachable address. The accepted hosts can be extended with the `--allowed-hosts` option or a project's `serveAllowedHosts` field, for example to reach a network-exposed server by hostname. ([#1270])
* Fixed syncback not removing stale `$properties` entries when Studio resets a property to its engine default. ([#1244])
[#1176]: https://github.com/rojo-rbx/rojo/pull/1176 [#1176]: https://github.com/rojo-rbx/rojo/pull/1176
[#1179]: https://github.com/rojo-rbx/rojo/pull/1179 [#1179]: https://github.com/rojo-rbx/rojo/pull/1179
@@ -60,6 +61,7 @@ Making a new release? Simply add the new header with the version and date undern
[#1266]: https://github.com/rojo-rbx/rojo/pull/1266 [#1266]: https://github.com/rojo-rbx/rojo/pull/1266
[#1267]: https://github.com/rojo-rbx/rojo/pull/1267 [#1267]: https://github.com/rojo-rbx/rojo/pull/1267
[#1270]: https://github.com/rojo-rbx/rojo/pull/1270 [#1270]: https://github.com/rojo-rbx/rojo/pull/1270
[#1244]: https://github.com/rojo-rbx/rojo/pull/1244
## [7.7.0-rc.1] (November 27th, 2025) ## [7.7.0-rc.1] (November 27th, 2025)

View File

@@ -0,0 +1,5 @@
---
source: tests/rojo_test/syncback_util.rs
expression: "String::from_utf8_lossy(&output.stdout)"
---
Writing default.project.json

View File

@@ -0,0 +1,59 @@
---
source: tests/tests/syncback.rs
expression: default.project.json
---
{
"name": "SyncbackTest",
"tree": {
"$className": "DataModel",
"Workspace": {
"$className": "Workspace",
"TestPart": {
"$className": "Part",
"$properties": {
"Anchored": true,
"Color": {
"Color3uint8": [
0,
0,
255
]
}
}
},
"$properties": {
"EnableSLIMAvatars": {
"Enum": 0
},
"ImprovedAnimationConstraint": {
"Enum": 0
},
"ImprovedPhysicsReplication": {
"Enum": 0
},
"LayeredClothingCacheOptimizations": {
"Enum": 0
},
"MeshStreamingAndImprovedLods": {
"Enum": 0
},
"NextGenerationReplication": {
"Enum": 0
},
"PlayerScriptsUseInputActionSystem": {
"Enum": 0
},
"UseFixedSimulation": {
"Enum": 0
},
"UseNewLuauTypeSolver": "Disabled",
"ValidateEnabledProximityPrompt": {
"Enum": 0
}
},
"$attributes": {
"Rojo_Target_CurrentCamera": "302d573157260ee80a3baa32000003b5"
}
}
}
}

View File

@@ -0,0 +1,21 @@
{
"name": "SyncbackTest",
"tree": {
"$className": "DataModel",
"Workspace": {
"$className": "Workspace",
"TestPart": {
"$className": "Part",
"$properties": {
"Transparency": 1.0,
"Anchored": true,
"Color": [
1.0,
0.0,
0.0
]
}
}
}
}
}

View File

@@ -344,6 +344,11 @@ pub fn syncback_project<'sync>(
let mut new_child_map = HashMap::new(); let mut new_child_map = HashMap::new();
let mut node_changed_map = Vec::new(); let mut node_changed_map = Vec::new();
// Tracks whether any stale default-valued properties were removed from
// project nodes. If so, we must reserialize even if
// project_node_should_reserialize wouldn't otherwise detect a change
// (it only compares node properties forward, not in reverse).
let mut removed_stale_properties = false;
let mut node_queue = VecDeque::with_capacity(1); let mut node_queue = VecDeque::with_capacity(1);
node_queue.push_back((&mut project.tree, old_inst, snapshot.new_inst())); node_queue.push_back((&mut project.tree, old_inst, snapshot.new_inst()));
@@ -402,9 +407,11 @@ pub fn syncback_project<'sync>(
// We only want to set properties if it needs it. // We only want to set properties if it needs it.
if !middleware.handles_own_properties() { if !middleware.handles_own_properties() {
removed_stale_properties |=
project_node_property_syncback_path(snapshot, new_inst, node); project_node_property_syncback_path(snapshot, new_inst, node);
} }
} else { } else {
removed_stale_properties |=
project_node_property_syncback_no_path(snapshot, new_inst, node); project_node_property_syncback_no_path(snapshot, new_inst, node);
} }
@@ -507,12 +514,18 @@ pub fn syncback_project<'sync>(
} }
let mut fs_snapshot = FsSnapshot::new(); let mut fs_snapshot = FsSnapshot::new();
let mut needs_reserialize = removed_stale_properties;
if !needs_reserialize {
for (node_properties, node_attributes, old_inst) in node_changed_map { for (node_properties, node_attributes, old_inst) in node_changed_map {
if project_node_should_reserialize(node_properties, node_attributes, old_inst)? { if project_node_should_reserialize(node_properties, node_attributes, old_inst)? {
fs_snapshot.add_file(project_path, serde_json::to_vec_pretty(&project)?); needs_reserialize = true;
break; break;
} }
} }
}
if needs_reserialize {
fs_snapshot.add_file(project_path, serde_json::to_vec_pretty(&project)?);
}
Ok(SyncbackReturn { Ok(SyncbackReturn {
fs_snapshot, fs_snapshot,
@@ -521,15 +534,18 @@ pub fn syncback_project<'sync>(
}) })
} }
/// Syncs properties from the new instance into the project node.
/// Returns `true` if any stale properties were removed (i.e. properties
/// that existed in the project node but are now at their engine default).
fn project_node_property_syncback( fn project_node_property_syncback(
_snapshot: &SyncbackSnapshot, _snapshot: &SyncbackSnapshot,
filtered_properties: UstrMap<&Variant>, filtered_properties: UstrMap<&Variant>,
new_inst: &Instance, new_inst: &Instance,
node: &mut ProjectNode, node: &mut ProjectNode,
) { ) -> bool {
let properties = &mut node.properties; let properties = &mut node.properties;
let mut attributes = BTreeMap::new(); let mut attributes = BTreeMap::new();
for (name, value) in filtered_properties { for (&name, &value) in &filtered_properties {
match value { match value {
Variant::Attributes(attrs) => { Variant::Attributes(attrs) => {
for (attr_name, attr_value) in attrs.iter() { for (attr_name, attr_value) in attrs.iter() {
@@ -552,14 +568,48 @@ fn project_node_property_syncback(
} }
} }
} }
// Remove stale properties: entries that exist in the project node's
// $properties but are no longer in the filtered (non-default) properties
// from the instance. This handles the case where Studio resets a property
// to its engine default — filter_properties won't include it, so we need
// to clean up the now-stale project entry.
let class_data = rbx_reflection_database::get()
.ok()
.and_then(|db| db.classes.get(new_inst.class.as_str()));
let len_before = properties.len();
properties.retain(|prop_name, _| {
if filtered_properties.contains_key(prop_name) {
return true;
}
// Only remove if the property has a known default value in the
// reflection database. If there's no default, the property might be
// absent from the instance for other reasons (e.g. unknown property),
// so we conservatively keep it.
if let Some(data) = &class_data {
if data.default_properties.contains_key(prop_name.as_str()) {
log::debug!(
"Removing stale property '{}' from project node for class '{}': \
value has been reset to engine default",
prop_name,
new_inst.class
);
return false;
}
}
true
});
let removed_stale = properties.len() < len_before;
node.attributes = attributes; node.attributes = attributes;
removed_stale
} }
fn project_node_property_syncback_path( fn project_node_property_syncback_path(
snapshot: &SyncbackSnapshot, snapshot: &SyncbackSnapshot,
new_inst: &Instance, new_inst: &Instance,
node: &mut ProjectNode, node: &mut ProjectNode,
) { ) -> bool {
let filtered_properties = snapshot let filtered_properties = snapshot
.get_path_filtered_properties(new_inst.referent()) .get_path_filtered_properties(new_inst.referent())
.unwrap(); .unwrap();
@@ -570,7 +620,7 @@ fn project_node_property_syncback_no_path(
snapshot: &SyncbackSnapshot, snapshot: &SyncbackSnapshot,
new_inst: &Instance, new_inst: &Instance,
node: &mut ProjectNode, node: &mut ProjectNode,
) { ) -> bool {
let filtered_properties = filter_properties(snapshot.project(), new_inst); let filtered_properties = filter_properties(snapshot.project(), new_inst);
project_node_property_syncback(snapshot, filtered_properties, new_inst, node) project_node_property_syncback(snapshot, filtered_properties, new_inst, node)
} }

View File

@@ -86,4 +86,7 @@ syncback_tests! {
sync_rules => ["src/module.modulescript", "src/text.text"], sync_rules => ["src/module.modulescript", "src/text.text"],
// Ensures that the `syncUnscriptable` setting works // Ensures that the `syncUnscriptable` setting works
unscriptable_properties => ["default.project.json"], unscriptable_properties => ["default.project.json"],
// Ensures that syncback correctly removes default values from projects rather
// than leaving an incorrect value.
project_default_properties_remove => ["default.project.json"],
} }