Enhance WhitespaceUtil and Reconciler to handle duplicate names and improve diffing logic

- Updated WhitespaceUtil to return distinct values for totalDiff and whitespaceDiff when differences exceed 100 lines, based on content changes.
- Introduced findDuplicateNames function in Reconciler to identify and skip instances with duplicate names, ensuring reliable syncing.
- Enhanced diffing logic to account for ambiguous paths and log skipped duplicates during the sync process.
- Improved error handling in syncback_dir_no_meta to log and skip children with duplicate names in the file system.
This commit is contained in:
Joe 2026-01-20 22:10:19 -06:00
parent 06457bc0db
commit 4a09689090
3 changed files with 191 additions and 18 deletions

View file

@ -15,6 +15,32 @@ local function isEmpty(table)
return next(table) == nil
end
-- Finds duplicate names among children and returns a set of names that are duplicated
local function findDuplicateNames(children: { Instance } | { string }, virtualInstances: { [string]: any }?): { [string]: boolean }
local nameCounts: { [string]: number } = {}
local duplicates: { [string]: boolean } = {}
for _, child in children do
local name: string
if typeof(child) == "Instance" then
name = child.Name
elseif virtualInstances then
-- child is a virtual instance ID
local virtualChild = virtualInstances[child]
name = virtualChild and virtualChild.Name or ""
else
continue
end
nameCounts[name] = (nameCounts[name] or 0) + 1
if nameCounts[name] > 1 then
duplicates[name] = true
end
end
return duplicates
end
local function fuzzyEq(a: number, b: number, epsilon: number): boolean
return math.abs(a - b) < epsilon
end
@ -119,9 +145,86 @@ local function diff(instanceMap, virtualInstances, rootId)
updated = {},
}
-- Count of locations with duplicate-named siblings
local skippedDuplicateCount = 0
-- Pre-scan to find ALL instances that are in an ambiguous path
-- (i.e., they or any of their ancestors have duplicate-named siblings)
local ambiguousIds: { [string]: boolean } = {}
local function markSubtreeAmbiguous(id)
if ambiguousIds[id] then
return
end
ambiguousIds[id] = true
local virtualInstance = virtualInstances[id]
if virtualInstance then
for _, childId in ipairs(virtualInstance.Children) do
markSubtreeAmbiguous(childId)
end
end
end
-- Recursively scan for duplicates and mark ambiguous subtrees
local function scanForDuplicates(id)
local virtualInstance = virtualInstances[id]
if not virtualInstance then
return
end
local instance = instanceMap.fromIds[id]
-- Detect duplicate names among real DOM children (if instance exists)
local realDuplicates: { [string]: boolean } = {}
if instance then
local realChildren = instance:GetChildren()
realDuplicates = findDuplicateNames(realChildren)
-- Count locations with duplicates
if next(realDuplicates) then
skippedDuplicateCount += 1
end
end
-- Detect duplicate names among virtual DOM children
local virtualDuplicates = findDuplicateNames(virtualInstance.Children, virtualInstances)
-- Merge duplicates from both sides
local allDuplicates: { [string]: boolean } = {}
for name in realDuplicates do
allDuplicates[name] = true
end
for name in virtualDuplicates do
allDuplicates[name] = true
end
-- Mark all children with duplicate names (and their entire subtrees) as ambiguous
for _, childId in ipairs(virtualInstance.Children) do
local virtualChild = virtualInstances[childId]
if virtualChild and allDuplicates[virtualChild.Name] then
markSubtreeAmbiguous(childId)
end
end
-- Recurse into non-ambiguous children to find deeper duplicates
for _, childId in ipairs(virtualInstance.Children) do
if not ambiguousIds[childId] then
scanForDuplicates(childId)
end
end
end
-- First pass: scan entire tree to find all ambiguous paths
scanForDuplicates(rootId)
-- Add a virtual instance and all of its descendants to the patch, marked as
-- being added.
-- being added. Skip ambiguous instances.
local function markIdAdded(id)
if ambiguousIds[id] then
return
end
local virtualInstance = virtualInstances[id]
patch.added[id] = virtualInstance
@ -132,6 +235,11 @@ local function diff(instanceMap, virtualInstances, rootId)
-- Internal recursive kernel for diffing an instance with the given ID.
local function diffInternal(id)
-- Skip entirely if this instance is in an ambiguous path
if ambiguousIds[id] then
return true
end
local virtualInstance = virtualInstances[id]
local instance = instanceMap.fromIds[id]
@ -155,6 +263,11 @@ local function diff(instanceMap, virtualInstances, rootId)
local changedProperties = {}
for propertyName, virtualValue in pairs(virtualInstance.Properties) do
-- Skip CanvasPosition on ScrollingFrame (runtime state, not meaningful for sync)
if propertyName == "CanvasPosition" and virtualInstance.ClassName == "ScrollingFrame" then
continue
end
local getProperySuccess, existingValueOrErr = getProperty(instance, propertyName)
if getProperySuccess then
@ -222,8 +335,18 @@ local function diff(instanceMap, virtualInstances, rootId)
-- 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
-- Skip TouchInterest (auto-created by Roblox when touch events are connected)
if childInstance.ClassName == "TouchInterest" then
continue
end
local childId = instanceMap.fromInstances[childInstance]
-- Skip children in ambiguous paths
if childId and ambiguousIds[childId] then
continue
end
if childId == nil then
-- pcall to avoid security permission errors
local success, skip = pcall(function()
@ -235,6 +358,14 @@ local function diff(instanceMap, virtualInstances, rootId)
continue
end
-- Check if this unmapped child has a duplicate name among siblings
-- If so, skip it (we can't reliably determine what to do with it)
local siblings = instance:GetChildren()
local siblingDuplicates = findDuplicateNames(siblings)
if siblingDuplicates[childInstance.Name] 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.
@ -253,6 +384,11 @@ local function diff(instanceMap, virtualInstances, rootId)
-- 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
-- Skip children in ambiguous paths
if ambiguousIds[childId] then
continue
end
local childInstance = instanceMap.fromIds[childId]
if childInstance == nil then
@ -267,6 +403,14 @@ local function diff(instanceMap, virtualInstances, rootId)
local diffSuccess, err = diffInternal(rootId)
-- Log count of skipped duplicates
if skippedDuplicateCount > 0 then
Log.warn(
"Skipped {} location(s) with duplicate-named siblings (cannot reliably sync)",
skippedDuplicateCount
)
end
if not diffSuccess then
return false, err
end