fix: remove old keys when updating key in mapped()

This commit is contained in:
littensy 2026-03-27 04:39:02 -07:00
parent 81d547ae82
commit e426e76ff1
2 changed files with 64 additions and 29 deletions

View file

@ -874,62 +874,76 @@ end
of an existing atom. If the key is omitted from the mapper's return value, of an existing atom. If the key is omitted from the mapper's return value,
the original key is preserved. the original key is preserved.
This function is optimized to minimize unnecessary updates by only calling
the mapper for entries that have changed since the last run.
@param getter A signal or function that returns a table @param getter A signal or function that returns a table
@param mapper Function that maps each entry from the source to a new entry in the mapped table @param mapper Function that maps each entry from the source to a new entry in the mapped table
@see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-mapper @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-mapper
]] ]]
local function mapped<KI, VI, KO, VO>(getter: Getter<{ [KI]: VI }>, mapper: (VI, KI) -> (VO, KO)): Getter<{ [KO]: VO }> local function mapped<KI, VI, KO, VO>(getter: Getter<{ [KI]: VI }>, mapper: (VI, KI) -> (VO, KO)): Getter<{ [KO]: VO }>
local currentInput: { [KI]: VI } = {} local nextInput: { [KI]: VI } = {}
local keyCache: { [KI]: KO } = {} local keyMap: { [KI]: KO } = {}
local function map(value: VI, key: KI): (VO, KO) local function callMapper(value: VI, key: KI): (VO, KO)
local prevSub = setActiveSub(nil) local prevSub = setActiveSub(nil)
local success, valueOut, keyOut = pcall(mapper, value, key) local success, valueOut, keyOut = pcall(mapper, value, key)
activeSub = prevSub activeSub = prevSub
if not success then if not success then
error(`mapped() transform function failed: {valueOut}`) error(`mapped() transform function failed: {valueOut}`)
end end
return valueOut, if keyOut == nil then key else keyOut :: any return valueOut, if keyOut == nil then key :: any else keyOut
end end
return computed(function(previousOutput) return computed(function(lastOutput)
local currentOutput: { [KO]: VO } = previousOutput or {} local nextOutput = lastOutput or {}
local previousInput = currentInput local prevInput = nextInput
currentInput = getter() nextInput = getter()
for keyIn in previousInput do -- For keys that were removed from the input, remove the corresponding
if currentInput[keyIn] ~= nil then -- keys from the output.
continue for prevKeyIn in prevInput do
end if nextInput[prevKeyIn] == nil then
local prevKeyOut = keyMap[prevKeyIn]
local prevKeyOut = keyCache[keyIn] if prevKeyOut ~= nil then
if nextOutput == lastOutput then
if prevKeyOut then nextOutput = table.clone(nextOutput)
if currentOutput == previousOutput then end
currentOutput = table.clone(currentOutput) nextOutput[prevKeyOut] = nil
keyMap[prevKeyIn] = nil
end end
currentOutput[prevKeyOut] = nil
keyCache[keyIn] = nil
end end
end end
for keyIn, valueIn in currentInput do -- For keys that are new or changed, pass it to the mapper and update
if valueIn == previousInput[keyIn] then -- the output. If the mapper returns a new key that is different from
-- the previous call, the old mapped key should be removed from the
-- output.
for nextKeyIn, nextValueIn in nextInput do
if nextValueIn == prevInput[nextKeyIn] then
continue continue
end end
local valueOut, keyOut = map(valueIn, keyIn) local nextValueOut, nextKeyOut = callMapper(nextValueIn, nextKeyIn)
local prevKeyOut = keyMap[nextKeyIn]
if currentOutput[keyOut] ~= valueOut then if prevKeyOut ~= nil and prevKeyOut ~= nextKeyOut then
if currentOutput == previousOutput then if nextOutput == lastOutput then
currentOutput = table.clone(currentOutput) nextOutput = table.clone(nextOutput)
end end
currentOutput[keyOut] = valueOut nextOutput[prevKeyOut] = nil
keyCache[keyIn] = keyOut nextOutput[nextKeyOut] = nextValueOut
keyMap[nextKeyIn] = nextKeyOut
elseif nextOutput[nextKeyOut] ~= nextValueOut then
if nextOutput == lastOutput then
nextOutput = table.clone(nextOutput)
end
nextOutput[nextKeyOut] = nextValueOut
keyMap[nextKeyIn] = nextKeyOut
end end
end end
return currentOutput return nextOutput
end) end)
end end

View file

@ -104,4 +104,25 @@ test("should map keys", function()
assert(state.D == 4, "should add d") assert(state.D == 4, "should add d")
end) end)
test("should remove old key if mapper returns a new key", function()
local src, setSrc = signal({ 1 })
local map = mapped(src, function(value: number, index: number)
return true, `{index}={value}`
end)
local state = map()
assert(state["1=1"], "should have mapped first element to key 1=1")
setSrc({ 2 })
state = map()
assert(state["1=1"] == nil, "should have removed old key 1=1")
assert(state["1=2"], "should have remapped element to key 1=2")
setSrc({ 3, 1 })
state = map()
assert(state["1=2"] == nil, "should have removed old key 1=2")
assert(state["1=3"], "should have remapped element to key 1=3")
assert(state["2=1"], "should have mapped second element to key 2=1")
end)
return {} return {}