Files
rojo/plugin/src/Reconciler/diff.lua
boatbomber 85655ca84f Heuristic hydration matching instead of hard dependency on GetChildren ordering (#1266)
### Summary

When two or more sibling instances share the same `Name` and
`ClassName`, Rojo's reconciler previously paired them with their
server-side counterparts purely by child order (first-unvisited match in
`GetChildren()` order). If the workspace child order ever diverged from
the server's, the wrong instance got paired so each duplicate could
inherit a sibling's properties.

This is the root of the #1257 bug: the welded parts would oscillate
between positions on each connect/disconnect because hydration kept
mis-pairing them. (#1265 stopped the sync fallback from scrambling child
order in the first place but this PR makes hydration robust even when
order *does* diverge.)

This PR makes `hydrate` break ties by comparing properties: when several
existing children match on `Name`+`ClassName`, it scores each candidate
by how many of the virtual instance's properties match the candidate's
live values, and picks the best. Order remains the tiebreak when scores
are equal, so behavior is unchanged for uniquely-named instances and for
indistinguishable siblings.

### Changes

- `trueEquals.lua`: extracted verbatim from `diff.lua` (the fuzzy
value-equality helper) so it can be shared. No behavior change;
`diff.lua` now requires it.
- `countMatchingProperties.lua`: added
`countMatchingProperties(instance, virtualInstance, instanceMap) ->
number`. Skips `Ref` properties (the instanceMap isn't fully built
mid-hydrate, so refs can't be decoded reliably, and they're a poor
disambiguator anyway) and any property that can't be read or decoded.
- `hydrate.lua`: See details below.

### Hydrate Changes

This touches `hydrate`, which runs over the whole tree on every
connect/resync, so I want state clearly that **the common path is faster
than before, not slower** even for parents with thousands of children!

The old algorithm was a nested scan: for each of `V` virtual children,
scan existing children until the first unvisited `Name`+`ClassName`
match. Two costs stand out:
- A `pcall` (to guard DataModel permission errors) ran on every
comparison (up to `V*E` `pcall`s per parent).
- Even for in-order trees the re-scanning of the visited prefix made it
`O(V^2)`.

The new algorithm does a single bucketing pass, then `O(1)` lookups:
1. One `O(E)` pass groups existing children into nested
`buckets[name][className]` tables. This runs exactly `E` `pcall`s total
(one per child), down from the `V*E` worst case.
2. Each virtual child does an `O(1)` bucket lookup to find its
candidates.
3. A per-bucket cursor skips already-paired children, so order-based
matching is amortized `O(1)` per child instead of rescanning.

| Scenario | Old | New |
| ------------------------------------------------ |
--------------------------------------- |
--------------------------------------------- |
| Unique-named children (typical, incl. thousands) | `O(V^2)`, plus up
to `V*E` pcalls | `O(V + E)`, plus exactly `E` pcalls |
| `C <= 32` candidates | `O(C^2)` | `O(P * C^2)` scoring |
| `C > 32` candidates | `O(C^2)` | `O(C)` |

Property scoring (`getProperty`/`decodeValue`/`trueEquals`) is the only
new expense, and it's gated two ways:
- It runs only when a `Name`+`ClassName` group has >=2 candidates (i.e.
never for uniquely-named instances).
- A cap, `MAX_CANDIDATES_TO_SCORE = 32`, means scoring only kicks in
once a group has <=32 unvisited candidates. A folder of thousands of
identically-named parts therefore does not trigger scoring; it falls
back to the original order-based pairing. The worst-case added scoring
work is bounded to roughly `32^2` property comparisons per group,
independent of group size.

So overall this is faster when you have unique names or many children.
It is slower but more robust when you have small groups of duplicate
names. Memory usage is increased as it creates the candidate buckets.
2026-06-01 17:29:22 -07:00

190 lines
5.5 KiB
Lua

--[[
Defines the process for diffing a virtual DOM and the real DOM to compute a
patch that can be later applied.
]]
local Packages = script.Parent.Parent.Parent.Packages
local Log = require(Packages.Log)
local invariant = require(script.Parent.Parent.invariant)
local getProperty = require(script.Parent.getProperty)
local Error = require(script.Parent.Error)
local decodeValue = require(script.Parent.decodeValue)
local trueEquals = require(script.Parent.trueEquals)
local function isEmpty(table)
return next(table) == nil
end
local function shouldDeleteUnknownInstances(virtualInstance)
if virtualInstance.Metadata ~= nil then
return not virtualInstance.Metadata.ignoreUnknownInstances
else
return true
end
end
local function diff(instanceMap, virtualInstances, rootId)
local patch = {
removed = {},
added = {},
updated = {},
}
-- Add a virtual instance and all of its descendants to the patch, marked as
-- being added.
local function markIdAdded(id)
local virtualInstance = virtualInstances[id]
patch.added[id] = virtualInstance
for _, childId in ipairs(virtualInstance.Children) do
markIdAdded(childId)
end
end
-- Internal recursive kernel for diffing an instance with the given ID.
local function diffInternal(id)
local virtualInstance = virtualInstances[id]
local instance = instanceMap.fromIds[id]
if virtualInstance == nil then
invariant("Cannot diff an instance not present in virtualInstances\nID: {}", id)
end
if instance == nil then
invariant("Cannot diff an instance not present in InstanceMap\nID: {}", id)
end
local changedClassName = nil
if virtualInstance.ClassName ~= instance.ClassName then
changedClassName = virtualInstance.ClassName
end
local changedName = nil
if virtualInstance.Name ~= instance.Name then
changedName = virtualInstance.Name
end
local changedProperties = {}
for propertyName, virtualValue in pairs(virtualInstance.Properties) do
local getProperySuccess, existingValueOrErr = getProperty(instance, propertyName)
if getProperySuccess then
local existingValue = existingValueOrErr
local decodeSuccess, decodedValue
-- If `virtualValue` is a ref then instead of decoding it to an instance,
-- we change `existingValue` to be a ref. This is because `virtualValue`
-- may point to an Instance which doesn't exist yet and therefore
-- decoding it may throw an error.
if next(virtualValue) == "Ref" then
decodeSuccess, decodedValue = true, virtualValue
if existingValue and typeof(existingValue) == "Instance" then
local existingValueRef = instanceMap.fromInstances[existingValue]
if existingValueRef then
existingValue = { Ref = existingValueRef }
end
end
else
decodeSuccess, decodedValue = decodeValue(virtualValue, instanceMap)
end
if decodeSuccess then
if not trueEquals(existingValue, decodedValue) then
Log.debug(
"{}.{} changed from '{}' to '{}'",
instance:GetFullName(),
propertyName,
existingValue,
decodedValue
)
changedProperties[propertyName] = virtualValue
end
else
Log.warn(
"Failed to decode property {}.{}. Encoded property was: {:#?}",
virtualInstance.ClassName,
propertyName,
virtualValue
)
end
else
local err = existingValueOrErr
if err.kind == Error.UnknownProperty then
Log.trace("Skipping unknown property {}.{}", err.details.className, err.details.propertyName)
else
Log.trace("Skipping unreadable property {}.{}", err.details.className, err.details.propertyName)
end
end
end
if changedName ~= nil or changedClassName ~= nil or not isEmpty(changedProperties) then
table.insert(patch.updated, {
id = id,
changedName = changedName,
changedClassName = changedClassName,
changedProperties = changedProperties,
changedMetadata = nil,
})
end
-- Traverse the list of children in the DOM. Any instance that has no
-- corresponding virtual instance should be removed. Any instance that
-- does have a corresponding virtual instance is recursively diffed.
for _, childInstance in ipairs(instance:GetChildren()) do
local childId = instanceMap.fromInstances[childInstance]
if childId == nil then
-- pcall to avoid security permission errors
local success, skip = pcall(function()
-- We don't remove instances that aren't going to be saved anyway,
-- such as the Rojo session lock value.
return childInstance.Archivable == false
end)
if success and skip then
continue
end
-- This is an existing instance not present in the virtual DOM.
-- We can mark it for deletion unless the user has asked us not
-- to delete unknown stuff.
if shouldDeleteUnknownInstances(virtualInstance) then
table.insert(patch.removed, childInstance)
end
else
local diffSuccess, err = diffInternal(childId)
if not diffSuccess then
return false, err
end
end
end
-- Traverse the list of children in the virtual DOM. Any virtual
-- instance that has no corresponding real instance should be created.
for _, childId in ipairs(virtualInstance.Children) do
local childInstance = instanceMap.fromIds[childId]
if childInstance == nil then
-- This instance is present in the virtual DOM, but doesn't
-- exist in the real DOM.
markIdAdded(childId)
end
end
return true
end
local diffSuccess, err = diffInternal(rootId)
if not diffSuccess then
return false, err
end
return true, patch
end
return diff