Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ Making a new release? Simply add the new header with the version and date undern

## Unreleased

* Fixed the "Always" confirmation behavior setting to prompt for confirmation on every sync patch, not just the initial sync (which is handled by "Initial", the default confirmation behavior setting). Changes that arrive during confirmation are merged into the pending patch and the UI updates in real-time. ([#1216])
* Fixed a bug caused by having reference properties (such as `ObjectValue.Value`) that point to an Instance not included in syncback. ([#1179])
* Fixed instance replacement fallback failing when too many instances needed to be replaced. ([#1192])
* Fixed a bug where MacOS paths weren't being handled correctly. ([#1201])
Expand All @@ -42,6 +43,7 @@ Making a new release? Simply add the new header with the version and date undern
[#1201]: https://github.com/rojo-rbx/rojo/pull/1201
[#1211]: https://github.com/rojo-rbx/rojo/pull/1211
[#1215]: https://github.com/rojo-rbx/rojo/pull/1215
[#1216]: https://github.com/rojo-rbx/rojo/pull/1216

## [7.7.0-rc.1] (November 27th, 2025)

Expand Down
5 changes: 5 additions & 0 deletions plugin/src/App/Components/PatchVisualizer/DomLabel.lua
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ function DomLabel:init()
end)
end

function DomLabel:willUnmount()
-- Stop the motor to prevent onStep callbacks after unmount
self.motor:stop()
end

function DomLabel:didUpdate(prevProps)
if
prevProps.instance ~= self.props.instance
Expand Down
2 changes: 1 addition & 1 deletion plugin/src/App/StatusPages/Settings/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ local function invertTbl(tbl)
end

local invertedLevels = invertTbl(Log.Level)
local confirmationBehaviors = { "Initial", "Always", "Large Changes", "Unlisted PlaceId", "Never" }
local confirmationBehaviors = { "Initial", "Always", "Every Change", "Large Changes", "Unlisted PlaceId", "Never" }
local syncReminderModes = { "None", "Notify", "Fullscreen" }

local function Navbar(props)
Expand Down
31 changes: 30 additions & 1 deletion plugin/src/App/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -821,7 +821,36 @@ function App:startSession()
timeout = 7,
})

return self.confirmationEvent:Wait()
local result = self.confirmationEvent:Wait()

-- Reset UI state back to Connected after confirmation
-- This is needed for ongoing WebSocket patches where the session
-- is already connected and won't trigger a status change
if self.serveSession and self.serveSession:getStatus() == ServeSession.Status.Connected then
self:setState({
appStatus = AppStatus.Connected,
toolbarIcon = Assets.Images.PluginButtonConnected,
-- Clear patchTree to avoid animation issues when the
-- PatchVisualizer unmounts while Flipper motors are running
patchTree = nil,
})
end

return result
end)

serveSession:setPatchUpdateCallback(function(instanceMap, patch)
-- If all changes have been reverted, auto-accept the empty patch
if PatchSet.isEmpty(patch) then
Log.trace("Patch became empty after merging, auto-accepting")
self.confirmationBindable:Fire("Accept")
return
end

-- Update the patchTree when new changes arrive during confirmation
self:setState({
patchTree = PatchTree.build(patch, instanceMap, { "Property", "Current", "Incoming" }),
})
end)

serveSession:start()
Expand Down
96 changes: 96 additions & 0 deletions plugin/src/PatchSet.lua
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ local Packages = script.Parent.Parent.Packages
local t = require(Packages.t)

local Types = require(script.Parent.Types)
local decodeValue = require(script.Parent.Reconciler.decodeValue)

local function deepEqual(a: any, b: any): boolean
local typeA = typeof(a)
Expand Down Expand Up @@ -282,6 +283,101 @@ function PatchSet.assign(target, ...)
return target
end

--[[
Merge a source PatchSet into the target, properly reconciling conflicts.
Unlike assign(), this handles cases where a removal cancels out an addition,
or where an update supersedes a previous update to the same instance.

If instanceMap is provided, updates that revert to the current instance state
will be removed from the patch entirely.
]]
function PatchSet.merge(target, source, instanceMap)
-- Process removals: if we're removing something that was added, just delete the addition
for _, removed in ipairs(source.removed) do
if target.added[removed] ~= nil then
-- This removal cancels out a previous addition
target.added[removed] = nil
else
table.insert(target.removed, removed)
end
end

-- Process additions
for id, added in pairs(source.added) do
target.added[id] = added
end

-- Process updates: merge with existing updates to the same instance
for _, update in ipairs(source.updated) do
local existingIndex = nil
for i, existing in ipairs(target.updated) do
if existing.id == update.id then
existingIndex = i
break
end
end

if existingIndex then
-- Merge the updates
local existing = target.updated[existingIndex]
if update.changedName ~= nil then
existing.changedName = update.changedName
end
if update.changedClassName ~= nil then
existing.changedClassName = update.changedClassName
end
if update.changedMetadata ~= nil then
existing.changedMetadata = update.changedMetadata
end
for prop, value in pairs(update.changedProperties) do
existing.changedProperties[prop] = value
end

-- Check if this update now matches current instance state (i.e., reverted)
-- If so, remove properties that match or remove the update entirely
if instanceMap then
local instance = instanceMap.fromIds[existing.id]
if instance then
-- Check if name change matches current
if existing.changedName ~= nil and existing.changedName == instance.Name then
existing.changedName = nil
end
-- Check if class change matches current
if existing.changedClassName ~= nil and existing.changedClassName == instance.ClassName then
existing.changedClassName = nil
end
-- Check each changed property against current instance value
for propName, encodedValue in pairs(existing.changedProperties) do
local decodeSuccess, decodedValue = decodeValue(encodedValue, instanceMap)
if decodeSuccess then
local currentSuccess, currentValue = pcall(function()
return instance[propName]
end)
if currentSuccess and deepEqual(decodedValue, currentValue) then
existing.changedProperties[propName] = nil
end
end
end

-- If no changes remain, remove the update entirely
local hasChanges = existing.changedName ~= nil
or existing.changedClassName ~= nil
or existing.changedMetadata ~= nil
or next(existing.changedProperties) ~= nil

if not hasChanges then
table.remove(target.updated, existingIndex)
end
end
end
else
table.insert(target.updated, update)
end
end

return target
end

function PatchSet.addedIdList(patchSet): { string }
local idList = table.create(#patchSet.added)
for id in patchSet.added do
Expand Down
Loading