rojo/plugin/src/Reconciler/applyPatch.lua
Joe 491506fafd Refactor log message formatting for timing information consistency
- Updated log messages across `ApiContext`, `ServeSession`, `Reconciler`, and `App` to use consistent formatting for timing information, replacing placeholder syntax with `string.format` for improved clarity.
- Enhanced trace logs to provide more precise execution time measurements during various operations, including connection, reading, writing, and patch application processes.

These changes aim to standardize logging practices and improve the readability of timing information in the application.
2026-03-01 14:20:09 -06:00

362 lines
12 KiB
Lua

--[[
Apply a patch to the DOM. Returns any portions of the patch that weren't
possible to apply.
Patches can come from the server or be generated by the client.
]]
local Packages = script.Parent.Parent.Parent.Packages
local Log = require(Packages.Log)
local Config = require(script.Parent.Parent.Config)
local PatchSet = require(script.Parent.Parent.PatchSet)
local Types = require(script.Parent.Parent.Types)
local invariant = require(script.Parent.Parent.invariant)
local decodeValue = require(script.Parent.decodeValue)
local reify = require(script.Parent.reify)
local reifyInstance, applyDeferredRefs = reify.reifyInstance, reify.applyDeferredRefs
local setProperty = require(script.Parent.setProperty)
local function applyPatch(instanceMap, patch)
local applyPatchClock = os.clock()
-- Tracks any portions of the patch that could not be applied to the DOM.
local unappliedPatch = PatchSet.newEmpty()
-- Contains a list of all of the ref properties that we'll need to assign.
-- It is imperative that refs are assigned after all instances are created
-- to ensure that referents can be mapped to instances correctly.
local deferredRefs = {}
local addCount = 0
for _ in pairs(patch.added) do
addCount += 1
end
if #patch.removed > 0 or addCount > 0 or #patch.updated > 0 then
Log.info(
"Applying patch to Studio: {} removals, {} additions, {} updates",
#patch.removed,
addCount,
#patch.updated
)
end
Log.trace(
"[TIMING] applyPatch() starting: {} removals, {} additions, {} updates",
#patch.removed,
addCount,
#patch.updated
)
local removalClock = os.clock()
for _, removedIdOrInstance in ipairs(patch.removed) do
-- Skip auto-created instances that shouldn't be synced
local instance = if Types.RbxId(removedIdOrInstance)
then instanceMap.fromIds[removedIdOrInstance]
else removedIdOrInstance
if instance and Config.ignoredClassNames[instance.ClassName] then
continue
end
-- Log the removal (info level for major operations)
local instancePath = if instance then instance:GetFullName() else tostring(removedIdOrInstance)
local instanceClass = if instance then instance.ClassName else "unknown"
Log.info("[Studio] Remove: {} ({})", instancePath, instanceClass)
local removeInstanceSuccess = pcall(function()
if Types.RbxId(removedIdOrInstance) then
instanceMap:destroyId(removedIdOrInstance)
else
instanceMap:destroyInstance(removedIdOrInstance)
end
end)
if not removeInstanceSuccess then
Log.warn("[Studio] Remove failed: {}", instancePath)
table.insert(unappliedPatch.removed, removedIdOrInstance)
end
end
Log.trace(
"[TIMING] applyPatch() removals completed ({} ms)",
string.format("%.1f", (os.clock() - removalClock) * 1000)
)
-- Detect echoed additions from DescendantAdded handler.
-- When the plugin pre-inserts instances with temp IDs and the server
-- broadcasts the same instances with real Refs, remap the InstanceMap
-- entries rather than creating duplicates via reify.
-- Only remap IDs explicitly tracked as scripts-only temp IDs to avoid
-- false positives with legitimate same-named additions.
if next(instanceMap.scriptsOnlyTempIds) then
local remapProgress = true
while remapProgress do
remapProgress = false
for id, virtualInstance in pairs(patch.added) do
local parentInstance = instanceMap.fromIds[virtualInstance.Parent]
if not parentInstance then
continue
end
for _, child in parentInstance:GetChildren() do
if child.Name == virtualInstance.Name and child.ClassName == virtualInstance.ClassName then
local existingId = instanceMap.fromInstances[child]
if existingId and existingId ~= id and instanceMap.scriptsOnlyTempIds[existingId] then
Log.trace("Echo remap: {} -> {} for {}", existingId, id, child:GetFullName())
instanceMap.scriptsOnlyTempIds[existingId] = nil
instanceMap:removeId(existingId)
instanceMap:insert(id, child)
patch.added[id] = nil
remapProgress = true
break
end
end
end
end
end
end
local additionClock = os.clock()
Log.trace("[TIMING] applyPatch() additions starting")
for id, virtualInstance in pairs(patch.added) do
if instanceMap.fromIds[id] ~= nil then
-- This instance already exists. We might've already added it in a
-- previous iteration of this loop, or maybe this patch was not
-- supposed to list this instance.
--
-- It's probably fine, right?
continue
end
-- Find the first ancestor of this instance that is marked for an
-- addition.
--
-- This helps us make sure we only reify each instance once, and we
-- start from the top.
while patch.added[virtualInstance.Parent] ~= nil do
id = virtualInstance.Parent
virtualInstance = patch.added[id]
end
local parentInstance = instanceMap.fromIds[virtualInstance.Parent]
if parentInstance == nil then
-- This would be peculiar. If you create an instance with no
-- parent, were you supposed to create it at all?
invariant(
"Cannot add an instance from a patch that has no parent.\nInstance {} with parent {}.\nState: {:#?}",
id,
virtualInstance.Parent,
instanceMap
)
end
-- Log the addition (info level for major operations)
local parentPath = parentInstance:GetFullName()
Log.info("[Studio] Add: {}.{} ({})", parentPath, virtualInstance.Name, virtualInstance.ClassName)
local failedToReify = reifyInstance(deferredRefs, instanceMap, patch.added, id, parentInstance)
if not PatchSet.isEmpty(failedToReify) then
Log.warn("[Studio] Add failed: {}.{}", parentPath, virtualInstance.Name)
Log.debug("Failed to reify as part of applying a patch: {:#?}", failedToReify)
PatchSet.assign(unappliedPatch, failedToReify)
end
end
Log.trace(
"[TIMING] applyPatch() additions completed ({} ms)",
string.format("%.1f", (os.clock() - additionClock) * 1000)
)
local updateClock = os.clock()
Log.trace("[TIMING] applyPatch() updates starting ({} updates)", #patch.updated)
for _, update in ipairs(patch.updated) do
local instance = instanceMap.fromIds[update.id]
if instance == nil then
-- We can't update an instance that doesn't exist.
Log.warn("[Studio] Update failed: Instance with ID {} not found", update.id)
table.insert(unappliedPatch.updated, update)
continue
end
local instancePath = instance:GetFullName()
local propList = {}
if update.changedName then
table.insert(propList, "Name")
end
if update.changedClassName then
table.insert(propList, "ClassName")
end
if update.changedProperties then
for propName, _ in pairs(update.changedProperties) do
table.insert(propList, propName)
end
end
Log.info("[Studio] Update: {} - {}", instancePath, table.concat(propList, ", "))
-- Pause updates on this instance to avoid picking up our changes when
-- two-way sync is enabled.
instanceMap:pauseInstance(instance)
-- Track any part of this update that could not be applied.
local unappliedUpdate = {
id = update.id,
changedProperties = {},
}
local partiallyApplied = false
-- If the instance's className changed, we have a bumpy ride ahead while
-- we recreate this instance and move all of its children into the new
-- version atomically...ish.
if update.changedClassName ~= nil then
-- If the instance's name also changed, we'll do it here, since this
-- branch will skip the rest of the loop iteration.
local newName = update.changedName or instance.Name
-- TODO: When changing between instances that have similar sets of
-- properties, like between an ImageLabel and an ImageButton, we
-- should preserve all of the properties that are shared between the
-- two classes unless they're changed as part of this patch. This is
-- similar to how "class changer" Studio plugins work.
--
-- For now, we'll only apply properties that are mentioned in this
-- update. Patches with changedClassName set only occur in specific
-- circumstances, usually between Folder and ModuleScript instances.
-- While this may result in some issues, like not preserving the
-- "Archived" property, a robust solution is sufficiently
-- complicated that we're pushing it off for now.
local newProperties = update.changedProperties
-- If the instance's ClassName changed, we'll kick into reify to
-- create this instance. We'll handle moving all of children between
-- the instances after the new one is created.
local mockVirtualInstance = {
Id = update.id,
Name = newName,
ClassName = update.changedClassName,
Properties = newProperties,
Children = {},
}
local mockAdded = {
[update.id] = mockVirtualInstance,
}
local failedToReify = reifyInstance(deferredRefs, instanceMap, mockAdded, update.id, instance.Parent)
local newInstance = instanceMap.fromIds[update.id]
-- Some parts of reify may have failed, but this is not necessarily
-- critical. If the instance wasn't recreated or has the wrong Name,
-- we'll consider our attempt a failure.
if instance == newInstance or newInstance.Name ~= newName then
table.insert(unappliedPatch.updated, update)
continue
end
-- Here are the non-critical failures. We know that the instance
-- succeeded in creating and that assigning Name did not fail, but
-- other property assignments might've failed.
if not PatchSet.isEmpty(failedToReify) then
PatchSet.assign(unappliedPatch, failedToReify)
end
-- Watch out, this is the scary part! Move all of the children of
-- instance into newInstance.
--
-- TODO: If this fails part way through, should we move everything
-- back? For now, we assume that moving things will not fail.
for _, child in ipairs(instance:GetChildren()) do
child.Parent = newInstance
end
-- See you later, original instance.
-- Because the user might want to Undo this change, we cannot use Destroy
-- since that locks that parent and prevents ChangeHistoryService from
-- ever bringing it back. Instead, we parent to nil.
-- TODO: Can this fail? Some kinds of instance may not appreciate
-- being reparented, like services.
instance.Parent = nil
-- This completes your rebuilding a plane mid-flight safety
-- instruction. Please sit back, relax, and enjoy your flight.
continue
end
if update.changedName ~= nil then
local setNameSuccess = pcall(function()
instance.Name = update.changedName
end)
if not setNameSuccess then
unappliedUpdate.changedName = update.changedName
partiallyApplied = true
end
end
if update.changedMetadata ~= nil then
-- TODO: Support changing metadata. This will become necessary when
-- Rojo persistently tracks metadata for each instance in order to
-- remove extra instances.
unappliedUpdate.changedMetadata = update.changedMetadata
partiallyApplied = true
end
if update.changedProperties ~= nil then
for propertyName, propertyValue in pairs(update.changedProperties) do
-- Because refs may refer to instances that we haven't constructed yet,
-- we defer applying any ref properties until all instances are created.
if next(propertyValue) == "Ref" then
table.insert(deferredRefs, {
id = update.id,
instance = instance,
propertyName = propertyName,
virtualValue = propertyValue,
})
continue
end
local decodeSuccess, decodedValue = decodeValue(propertyValue, instanceMap)
if not decodeSuccess then
unappliedUpdate.changedProperties[propertyName] = propertyValue
partiallyApplied = true
continue
end
local setPropertySuccess = setProperty(instance, propertyName, decodedValue)
if not setPropertySuccess then
unappliedUpdate.changedProperties[propertyName] = propertyValue
partiallyApplied = true
end
end
end
if partiallyApplied then
table.insert(unappliedPatch.updated, unappliedUpdate)
end
end
Log.trace(
"[TIMING] applyPatch() updates completed ({} ms)",
string.format("%.1f", (os.clock() - updateClock) * 1000)
)
local refsClock = os.clock()
Log.trace("[TIMING] applyPatch() deferred refs starting ({} refs)", #deferredRefs)
applyDeferredRefs(instanceMap, deferredRefs, unappliedPatch)
Log.trace(
"[TIMING] applyPatch() deferred refs completed ({} ms)",
string.format("%.1f", (os.clock() - refsClock) * 1000)
)
Log.trace(
"[TIMING] applyPatch() total completed ({} ms)",
string.format("%.1f", (os.clock() - applyPatchClock) * 1000)
)
return unappliedPatch
end
return applyPatch