Make "Always" confirmation behavior setting work by handling it for ongoing sync patches

- Add "Always" option to confirmationBehavior setting
- Route WebSocket patches through the confirmation callback
- Fix UI not transitioning back to Connected after confirming ongoing patches
- Fix DomLabel Flipper motor cleanup on unmount to prevent errors
This commit is contained in:
warsam 2026-01-23 19:39:29 -08:00 • committed by Joe
parent a7f36fab5e
commit a05acf8d35
7 changed files with 638 additions and 90 deletions

View file

@ -105,6 +105,10 @@ function ServeSession.new(options)
__instanceMap = instanceMap,
__changeBatcher = changeBatcher,
__statusChangedCallback = nil,
__userConfirmCallback = nil,
__patchUpdateCallback = nil,
__serverInfo = nil,
__confirmingPatch = nil,
__connections = connections,
__precommitCallbacks = {},
__postcommitCallbacks = {},
@ -139,6 +143,10 @@ function ServeSession:setConfirmCallback(callback)
self.__userConfirmCallback = callback
end
function ServeSession:setPatchUpdateCallback(callback)
self.__patchUpdateCallback = callback
end
function ServeSession:setUpdateLoadingTextCallback(callback)
self.__updateLoadingText = callback
end
@ -189,6 +197,62 @@ function ServeSession:hookPostcommit(callback)
end
end
function ServeSession:__onWebSocketMessage(messagesPacket)
if self.__status == Status.Disconnected then
return
end
Log.debug("Received {} messages from Rojo server", #messagesPacket.messages)
-- Combine all messages into a single patch
local combinedPatch = PatchSet.newEmpty()
for _, message in messagesPacket.messages do
PatchSet.assign(combinedPatch, message)
end
-- If we're already waiting for confirmation, merge into the patch being confirmed
if self.__confirmingPatch ~= nil then
Log.trace("Already confirming, merging into current patch")
PatchSet.merge(self.__confirmingPatch, combinedPatch, self.__instanceMap)
-- Notify the UI to update the displayed patch
if self.__patchUpdateCallback ~= nil then
self.__patchUpdateCallback(self.__instanceMap, self.__confirmingPatch)
end
self.__apiContext:setMessageCursor(messagesPacket.messageCursor)
return
end
-- Set confirming patch before spawning to prevent race condition
-- where another message arrives before the spawned thread starts
self.__confirmingPatch = combinedPatch
-- Spawn a new thread to handle potentially yielding confirmation
task.spawn(function()
Log.trace("Processing WebSocket patch, callback exists: {}", self.__userConfirmCallback ~= nil)
Log.trace("ServerInfo exists: {}", self.__serverInfo ~= nil)
local userDecision = "Accept"
if self.__userConfirmCallback ~= nil then
userDecision = self.__userConfirmCallback(self.__instanceMap, combinedPatch, self.__serverInfo)
end
self.__confirmingPatch = nil
Log.trace("WebSocket patch decision: {}", userDecision)
if userDecision == "Abort" then
self:__stopInternal("Aborted Rojo sync operation")
return
elseif userDecision == "Accept" then
-- combinedPatch may have been updated with additional changes
-- that arrived while we were waiting for confirmation
self:__applyPatch(combinedPatch)
end
-- Note: "Reject" is not handled for ongoing patches, only for initial sync
end)
self.__apiContext:setMessageCursor(messagesPacket.messageCursor)
end
function ServeSession:start()
self:__setStatus(Status.Connecting)
self:setLoadingText("Connecting to server...")
@ -202,25 +266,26 @@ function ServeSession:start()
self.__syncSourceOnly = serverInfo.syncSourceOnly or false
self.__changeBatcher:setSyncSourceOnly(self.__syncSourceOnly)
return self:__initialSync(serverInfo):andThen(function()
self.__serverInfo = serverInfo
return self:__computeInitialPatch(serverInfo):andThen(function(catchUpPatch)
self:setLoadingText("Starting sync loop...")
self:__setStatus(Status.Connected, serverInfo.projectName)
self:__applyGameAndPlaceId(serverInfo)
return self.__apiContext:connectWebSocket({
-- Connect WebSocket BEFORE showing confirmation so changes
-- during confirmation can be merged into the patch
-- Note: connectWebSocket returns a Promise that only resolves when
-- the connection closes, so we don't chain .andThen() on it
self.__apiContext:connectWebSocket({
["messages"] = function(messagesPacket)
if self.__status == Status.Disconnected then
return
end
Log.debug("Received {} messages from Rojo server", #messagesPacket.messages)
for _, message in messagesPacket.messages do
self:__applyPatch(message)
end
self.__apiContext:setMessageCursor(messagesPacket.messageCursor)
self:__onWebSocketMessage(messagesPacket)
end,
})
}):catch(function(err)
if self.__status ~= Status.Disconnected then
self:__stopInternal(err)
end
end)
-- Now show confirmation and wait for user decision
return self:__confirmAndApplyInitialPatch(catchUpPatch, serverInfo)
end)
end)
:catch(function(err)
@ -471,7 +536,7 @@ function ServeSession:__applyPatch(patch)
end
end
function ServeSession:__initialSync(serverInfo)
function ServeSession:__computeInitialPatch(serverInfo)
return self.__apiContext:read({ serverInfo.rootInstanceId }):andThen(function(readResponseBody)
-- Tell the API Context that we're up-to-date with the version of
-- the tree defined in this response.
@ -509,62 +574,72 @@ function ServeSession:__initialSync(serverInfo)
Log.trace("Computed hydration patch: {:#?}", debugPatch(catchUpPatch))
local userDecision = "Accept"
if self.__userConfirmCallback ~= nil then
userDecision = self.__userConfirmCallback(self.__instanceMap, catchUpPatch, serverInfo)
return catchUpPatch
end)
end
function ServeSession:__confirmAndApplyInitialPatch(catchUpPatch, serverInfo)
local userDecision = "Accept"
if self.__userConfirmCallback ~= nil then
-- Set confirming patch so WebSocket messages that arrive during
-- confirmation can be merged into this patch
self.__confirmingPatch = catchUpPatch
userDecision = self.__userConfirmCallback(self.__instanceMap, catchUpPatch, serverInfo)
self.__confirmingPatch = nil
end
if userDecision == "Abort" then
return Promise.reject("Aborted Rojo sync operation")
elseif userDecision == "Reject" then
if not self.__twoWaySync then
return Promise.reject("Cannot reject sync operation without two-way sync enabled")
end
-- The user wants their studio DOM to write back to their Rojo DOM
-- so we will reverse the patch and send it back
if userDecision == "Abort" then
return Promise.reject("Aborted Rojo sync operation")
elseif userDecision == "Reject" then
if not self.__twoWaySync then
return Promise.reject("Cannot reject sync operation without two-way sync enabled")
local inversePatch = PatchSet.newEmpty()
-- Send back the current properties
for _, change in catchUpPatch.updated do
local instance = self.__instanceMap.fromIds[change.id]
if not instance then
continue
end
-- The user wants their studio DOM to write back to their Rojo DOM
-- so we will reverse the patch and send it back
local inversePatch = PatchSet.newEmpty()
-- Send back the current properties
for _, change in catchUpPatch.updated do
local instance = self.__instanceMap.fromIds[change.id]
if not instance then
local propertiesToSync = change.changedProperties
if self.__syncSourceOnly then
-- Filter to only Source property when server is in source-only mode
if propertiesToSync.Source then
propertiesToSync = { Source = propertiesToSync.Source }
else
continue
end
local propertiesToSync = change.changedProperties
if self.__syncSourceOnly then
-- Filter to only Source property when server is in source-only mode
if propertiesToSync.Source then
propertiesToSync = { Source = propertiesToSync.Source }
else
continue
end
end
local update = encodePatchUpdate(instance, change.id, propertiesToSync)
if update then
table.insert(inversePatch.updated, update)
end
end
-- Add the removed instances back to Rojo
-- selene:allow(empty_if, unused_variable, empty_loop)
for _, instance in catchUpPatch.removed do
-- TODO: Generate ID for our instance and add it to inversePatch.added
end
-- Remove the additions we've rejected
for id, _change in catchUpPatch.added do
table.insert(inversePatch.removed, id)
end
return self.__apiContext:write(inversePatch)
elseif userDecision == "Accept" then
self:__applyPatch(catchUpPatch)
return Promise.resolve()
else
return Promise.reject("Invalid user decision: " .. userDecision)
local update = encodePatchUpdate(instance, change.id, propertiesToSync)
if update then
table.insert(inversePatch.updated, update)
end
end
end)
-- Add the removed instances back to Rojo
-- selene:allow(empty_if, unused_variable, empty_loop)
for _, instance in catchUpPatch.removed do
-- TODO: Generate ID for our instance and add it to inversePatch.added
end
-- Remove the additions we've rejected
for id, _change in catchUpPatch.added do
table.insert(inversePatch.removed, id)
end
return self.__apiContext:write(inversePatch)
elseif userDecision == "Accept" then
self:__setStatus(Status.Connected, serverInfo.projectName)
self:__applyGameAndPlaceId(serverInfo)
self:__applyPatch(catchUpPatch)
return Promise.resolve()
else
return Promise.reject("Invalid user decision: " .. userDecision)
end
end
function ServeSession:__stopInternal(err)