diff --git a/src/apply.luau b/src/apply.luau index d65ea7e..f22782b 100644 --- a/src/apply.luau +++ b/src/apply.luau @@ -1,13 +1,7 @@ -local typeof = typeof -local Vector2 = Vector2 -local UDim2 = UDim2 - -if not game then - script = require "test/relative-string" - typeof = require "test/mock".typeof - Vector2 = require "test/mock".Vector2 - UDim2 = require "test/mock".UDim2 -end +if not game then script = require "test/relative-string" end +local typeof = game and typeof or require "test/mock".typeof :: never +local Vector2 = game and Vector2 or require "test/mock".Vector2 :: never +local UDim2 = game and UDim2 or require "test/mock".UDim2 :: never local flags = require(script.Parent.flags) local throw = require(script.Parent.throw) @@ -16,9 +10,11 @@ local _, is_action = require(script.Parent.action)() local graph = require(script.Parent.graph) type Node = graph.Node +-- buffer of event -> callback to connect after properties are set local event_buffer: { [string]: () -> () } = {} -local action_buffers = {} :: { { () -> () } } +-- buffer of priority -> callback to run after events are connected +local action_buffers = {} :: { { () -> () } } setmetatable(action_buffers :: any, { __index = function(_, i: number) action_buffers[i] = {} @@ -26,8 +22,8 @@ setmetatable(action_buffers :: any, { end }) +-- cache used in strict mode to detect duplicate property sets at same nesting levels local nested_debug_cache: { [number]: { [string]: true } } = {} - setmetatable(nested_debug_cache :: any, { __index = function(_, i: number) nested_debug_cache[i] = {} @@ -35,10 +31,15 @@ setmetatable(nested_debug_cache :: any, { end }) +-- a stack used in place of a recursive function to process nesting layers one at a time +-- enforces the behavior of deeper-nested properties taking precedence of lesser-nested ones +-- each nested table occupies two indexes, reference to table itself and the depth number +-- e.g. props = { t1 = { t3 = {} }, t2 = {} } -> { t1, 1, t2, 1, t3, 2 } local nested_stack = {} :: { {} | number } --- todo -local classes = { +-- todo: solution without manual updating of this table +-- map of datatype names to class default constructor for aggregate initialization +local aggregates = { Vector2 = Vector2, UDim2 = UDim2, UDim = UDim2, @@ -46,11 +47,12 @@ local classes = { Color3 = Color3 } -local function get_class(v: unknown): { new: (...any) -> () } - return classes[typeof(v)] +for i, v in next, aggregates do + aggregates[i] = v.new end -local function process(instance: Instance, properties: { [unknown]: unknown }) +-- processes a potentially nested table of values to assign to an instance +local function process_nested(instance: Instance, properties: { [unknown]: unknown }) local strict = flags.strict table.clear(nested_stack) @@ -61,71 +63,79 @@ local function process(instance: Instance, properties: { [unknown]: unknown }) repeat for property, value in properties do if type(property) == "string" then - if strict then + if strict then -- check for duplicate prop assignment at nesting layer if nested_debug_cache[depth][property] then throw(`duplicate property {property} at depth {depth}`) end nested_debug_cache[depth][property] = true end - if type(value) == "table" then - local class = get_class((instance :: any)[property]) - if class == nil then + if type(value) == "table" then -- attempt aggregate init + local ctor = aggregates[typeof((instance :: any)[property])] + if ctor == nil then throw(`cannot aggregate construct type {typeof(value)} for property {property}`) end - (instance :: any)[property] = class.new(unpack(value :: {})) - elseif type(value) == "function" then + (instance :: any)[property] = ctor(unpack(value :: {})) + elseif type(value) == "function" then if typeof((instance :: any)[property]) == "RBXScriptSignal" then - event_buffer[property] = value :: () -> () + event_buffer[property] = value :: () -> () -- add event to buffer else - bind.property(instance, property, value :: () -> ()) + bind.property(instance, property, value :: () -> ()) -- bind source end else - (instance :: any)[property] = value + (instance :: any)[property] = value -- set property end elseif type(property) == "number" then if type(value) == "function" then - bind.children(instance, value :: () -> { Instance }) + bind.children(instance, value :: () -> { Instance }) -- bind children elseif type(value) == "table" then if is_action(value) then - table.insert(action_buffers[(value :: any).priority], (value :: any).callback :: () -> ()) + table.insert(action_buffers[(value :: any).priority], (value :: any).callback :: () -> ()) -- add action to buffer else - table.insert(nested_stack, depth + 1) + table.insert(nested_stack, depth + 1) -- push table to stack for later processing table.insert(nested_stack, value :: {}) end else - (value :: Instance).Parent = instance + (value :: Instance).Parent = instance -- parent child end end end - properties = table.remove(nested_stack) :: {} - depth = table.remove(nested_stack) :: number + -- pop next nested table off stack + properties = table.remove(nested_stack) :: {} + depth = table.remove(nested_stack) :: number until not properties end +-- applies table of nested properties to an instance using full vide semantics local function apply(instance: T & Instance, properties: { [unknown]: unknown }): T + -- queue parent assignment if any for last local parent: unknown = properties.Parent if parent then properties.Parent = nil end + -- reset buffers table.clear(event_buffer) for _, buffer in next, action_buffers do table.clear(buffer) end - process(instance, properties) + -- process all properties for immediate setting or buffering + process_nested(instance, properties) + -- connect buffered events for event, fn in next, event_buffer do (instance :: any)[event]:Connect(fn) end + -- run buffered actions respecting their priorities for _, buffer in next, action_buffers do for _, callback in next, buffer do callback() end end + -- finally set parent if any if parent then if type(parent) == "function" then error("cannot set parent to state") diff --git a/src/bind.luau b/src/bind.luau index d7ef2f7..7a08292 100644 --- a/src/bind.luau +++ b/src/bind.luau @@ -1,22 +1,41 @@ -local warn = warn - -if not game then - script = require "test/relative-string" - warn = print -end +if not game then script = require "test/relative-string" end +local warn = game and warn or print :: never +local throw = require(script.Parent.throw) +local flags = require(script.Parent.flags) local graph = require(script.Parent.graph) type Node = graph.Node local set_effect = graph.set_effect local capture = graph.capture -local throw = require(script.Parent.throw) -local flags = require(script.Parent.flags) +--[[ -local hold: { Instance? } = {} -local weak: { Instance? } = setmetatable({}, { __mode = "v" }) :: any -local bindcount = 0 +Roblox instances in Luau are referenced using a kind of userdata proxy, +this proxy can be garbage collected independently from the actual instance, even +if the instance is still parented. Since reactive bindings allow the garbage +collection of instances, this proxy can can garbage collected while the instance +is still parented, causing the binding to be lost and no longer update the +instance on changes. +Vide's solution to this is to hold the proxy in memory as long as the instance +is parented to the datamodel by using `GetPropertyChanged("Parent")` to add or +remove the proxy from a table whose sole purpose is to strongly reference +proxies. + +todo: investigate behavior in case B is parented to A, and A has no parent or reference, and B has a binding. + +]] + +-- holds parented instance proxies in memory +local hold: { Instance? } = {} + +-- weakly references instances with properties bound +local weak: { Instance? } = setmetatable({}, { __mode = "v" }) :: any + +-- unique binding id +local bind_count = 0 + +-- todo: replace with throw's method local root do local src = debug.info(1, "s") root = string.sub(src, 1, #src - 5) @@ -39,33 +58,40 @@ local function traceback(skips: number) -- ensures trace begins outside of any v return debug.traceback(nil, s) end -function setup(instance: Instance, debug_msg: string, setter: (Instance) -> ()) +function bind(instance: Instance, property: string, setter: (Instance) -> ()) if flags.strict then + -- wrap setter in function with stack inspection for better error msgs local fn = setter local bind_trace = traceback(0) setter = function(instance) local ok, err: string? = xpcall(fn, function(err: string) return err .. "\nsource updated at: " .. traceback(2) end, instance) - if not ok then warn(`error occured updating {debug_msg}: {err}bound at: {bind_trace}`) end + if not ok then warn(`error occured updating {property}: {err}bound at: {bind_trace}`) end end end + -- run setter to capture any nodes being depended on local nodes = (capture(setter :: () -> unknown, instance)) + -- register the setter as a side-effect of each node for _, node in next, nodes do set_effect(node, setter, instance) end - bindcount += 1 - local key = bindcount + -- get binding id + bind_count += 1 + local bind_id = bind_count - weak[key] = instance + -- store reference of instance proxy without preventing gc + weak[bind_id] = instance local function ref() - local _ = setter - local instance = weak[key] :: Instance - hold[key] = instance.Parent and instance or nil -- prevent gc of instance while parented + local _ = setter -- prevent gc of nodes being depended on + local instance = weak[bind_id] :: Instance + + -- keep proxy in memory if instance is still parented + hold[bind_id] = instance.Parent and instance or nil end ref() @@ -73,7 +99,7 @@ function setup(instance: Instance, debug_msg: string, setter: (Instance) -> ()) end local function bind_property(instance: Instance, property: string, fn: () -> unknown) - setup(instance, property, function(instance_weak: any) + bind(instance, property, function(instance_weak: any) instance_weak[property] = fn() end) end @@ -83,7 +109,7 @@ local function bind_parent(instance: Instance, fn: () -> Instance?) instance = nil :: any -- allow gc when destroyed end) - setup(instance, "Parent", function(instance) + bind(instance, "Parent", function(instance) local _ = instance -- state will strongly reference instance when parent is bound instance.Parent = fn() end) @@ -93,7 +119,7 @@ local function bind_children(parent: Instance, fn: () -> { Instance }) local current_child_set: { [Instance]: true } = {} -- cache of all children parented before update local new_child_set: { [Instance]: true } = {} -- cache of all children parented after update - setup(parent, "Children", function(parent_weak) + bind(parent, "Children", function(parent_weak) local new_childs = fn() -- all (and only) children that should be parented after this update if new_childs and type(new_childs) ~= "table" then throw(`Cannot parent instance of type { type(new_childs) } `) diff --git a/src/cleanup.luau b/src/cleanup.luau index 45f2978..d3da68a 100644 --- a/src/cleanup.luau +++ b/src/cleanup.luau @@ -3,15 +3,41 @@ if not game then script = require "test/relative-string" end local flags = require(script.Parent.flags) local throw = require(script.Parent.throw) +--[[ + +Cleanups associate a callback with an arbitrary value with an unknown lifetime. +Anytime a new callback is registered with a value that already has one registered, +the registered callback is ran and then replaced with the new one. + +When the value is eventually garbage collected, Vide checks for callbacks +without an associated value, which it will then run and clear, recycling its +cleanup id. + +By default the arbitrary value is the function object that calls `cleanup()`. +There are exceptions such as with `indexes()` and `values()` where the +arbitrary value is manually set to be the new source created instead of the +caller, as the same caller can be used to create multiple new objects. + +todo: remove need for ref to id maps? + +]] + +-- maps a ref to cleanup id local ref_to_id = {} :: { [string]: number } +-- maps a cleanup id to a ref local id_to_ref = {} :: { [number]: string } +-- array of all cleanup callbacks local cleanup_callbacks = {} :: { [number]: () -> () } -- always dense +-- weak array of all cleanup lifetimes local cleanup_lifetime = {} :: { [number]: unknown } -- can be sparse setmetatable(cleanup_lifetime :: any, { __mode = "v" }) +-- detects in strict mode when multiple cleanups are registered in the same scope local debug_caller_to_line = {} :: { [() -> ()]: number } setmetatable(debug_caller_to_line, { __mode = "k" }) +-- when active, cleanup callbacks are not automatically registered but are +-- added to an array for manual registering internally local manual_mode = { caller = false :: false | () -> (), callbacks = {} :: { () -> () } @@ -20,13 +46,14 @@ local manual_mode = { -- todo: rare case where mem address is reused by another function -- does this case handle itself? +-- registers a callback with the given lifetime using the given ref local function cleanup_ref(ref: string, lifetime: unknown, callback: () -> ()) local id = ref_to_id[ref] - if id then + if id then -- invoke previously registered callback then register new one cleanup_callbacks[id]() cleanup_lifetime[id] = lifetime -- rare case where ref is reused while lifetime is nil - else + else -- no previously registered callback, add and register new one id = #cleanup_callbacks + 1 ref_to_id[ref] = id id_to_ref[id :: any] = ref -- todo @@ -36,6 +63,7 @@ local function cleanup_ref(ref: string, lifetime: unknown, callback: () -> ()) cleanup_callbacks[id] = callback end +-- registers a callback with its caller as the lifetime, and caller address as the ref local function cleanup(callback: () -> ()) local lifetime = debug.info(2, "f") -- `caller of cleanup() is lifetime of cleanup` diff --git a/src/create.luau b/src/create.luau index ab3e93c..736ed26 100644 --- a/src/create.luau +++ b/src/create.luau @@ -1,22 +1,17 @@ -local Instance = Instance -local typeof = typeof - -if not game then - script = require "test/relative-string" - Instance = require("test/mock").Instance - typeof = require("test/mock").typeof -end +if not game then script = require "test/relative-string" end +local typeof = game and typeof or require "test/mock".typeof:: never +local Instance = game and Instance or require "test/mock".Instance :: never local throw = require(script.Parent.throw) local defaults = require(script.Parent.defaults) local apply = require(script.Parent.apply) local memoize = require(script.Parent.memoize) -local function create_instance(className: string) - local success, instance: Instance = pcall(Instance.new, className :: any) - if success == false then throw(`invalid class name, could not create instance of class { className }`) end +local function create_instance(class_name: string) + local ok, instance: Instance = pcall(Instance.new, class_name :: any) + if not ok then throw(`invalid class name, could not create instance of class { class_name }`) end - local default: { [string]: unknown }? = defaults[className] + local default: { [string]: unknown }? = defaults[class_name] if default then for i, v in next, default do (instance :: any)[i] = v @@ -26,7 +21,7 @@ local function create_instance(className: string) return function(properties: { [any]: unknown }): Instance return apply(instance:Clone(), properties) end -end; create_instance = memoize(create_instance) +end; create_instance = memoize(create_instance) -- always return same constructor for given class local function clone_instance(instance: Instance) return function(properties: { [any]: unknown }): Instance @@ -36,14 +31,15 @@ local function clone_instance(instance: Instance) end end -local function create(classNameOrInstance: string|Instance) - if type(classNameOrInstance) == "string" then - return create_instance(classNameOrInstance) - elseif typeof(classNameOrInstance) == "Instance" then - return clone_instance(classNameOrInstance) +local function create(class_or_instance: string|Instance) + if type(class_or_instance) == "string" then + return create_instance(class_or_instance) + elseif typeof(class_or_instance) == "Instance" then + return clone_instance(class_or_instance) else - error("Bad argument #1, expected string or instance, got "..typeof(classNameOrInstance), 2) + throw("bad argument #1, expected string or instance, got "..typeof(class_or_instance)) end + return nil :: never end type Props = { [any]: any } diff --git a/src/defaults.luau b/src/defaults.luau index d017d35..4770365 100644 --- a/src/defaults.luau +++ b/src/defaults.luau @@ -1,11 +1,5 @@ -local Enum = Enum -local Color3 = Color3 - -if not game then - local mock = require "test/mock" - Enum = mock.Enum - Color3 = mock.Color3 -end +local Enum = game and Enum or require "test/mock".Enum :: never +local Color3 = game and Color3 or require "test/mock".Color3 :: never return { Part = { diff --git a/src/derive.luau b/src/derive.luau index f3a9ca3..4d08437 100644 --- a/src/derive.luau +++ b/src/derive.luau @@ -5,11 +5,11 @@ local create = graph.create local capture_and_link = graph.capture_and_link local function derive(fn: () -> T): () -> T - local node, node_get = create((nil :: any) :: T) + local node, read_node_value = create((false :: any) :: T) node.cache = capture_and_link(node, fn) - return node_get + return read_node_value end return derive diff --git a/src/graph.luau b/src/graph.luau index 3aef02a..5de3137 100644 --- a/src/graph.luau +++ b/src/graph.luau @@ -10,7 +10,9 @@ export type Node = { children: { Node } | false -- weak values } +-- flag used to detect when node reference capturing is active local reff = false +-- array of all nodes referenced since above flag was set local refs = {} :: { Node } local WEAK_VALUES_RESIZABLE = { __mode = "vs" } @@ -18,6 +20,7 @@ local EVALUATION_ERR = "error while evaluating source:\n\n" setmetatable(refs :: any, WEAK_VALUES_RESIZABLE) +-- runs a given callback in a context that Luau does not allow yielding in local check_for_yield: (fn: (T...) -> unknown, T...) -> () do local t = { __mode = "kv" } setmetatable(t, t) @@ -43,12 +46,23 @@ local check_for_yield: (fn: (T...) -> unknown, T...) -> () do end end +--[[ + +Each node side-effect is registered with a corresponding weak key. +This makes the lifetime of the side-effect tied to the key's. +The main usecase of this is to tie a side-effect to an instance, while allowing +the instance to be garbage collected even when the node still exists. + +The weak key is passed as an argument to its side-effect callback. + +]] + local function set_effect(node: Node, fn: (T) -> (), key: T) node.effects[fn :: () -> ()] = key end local function run_effects(node: Node) - if flags.strict then + if flags.strict then -- run effects twice if strict for effect, key in next, node.effects do effect(key) effect(key) @@ -61,12 +75,13 @@ local function run_effects(node: Node) end -- retrieves a node's cached value --- recalculates value if an ancestor was updated +-- add self to refs if ref capture flag is enabled local function get(node: Node): T if reff then table.insert(refs, node) end return node.cache end +-- links two nodes as parent-child local function set_child(parent: Node, child: Node) if parent.children then table.insert(parent.children, child) @@ -144,11 +159,11 @@ local function create(value: T): (Node, () -> T) children = false :: false } - local function get_value() + local function read_node_value() return get(node) end - return node, get_value + return node, read_node_value end return table.freeze { diff --git a/src/init.luau b/src/init.luau index adb7061..5a5c723 100644 --- a/src/init.luau +++ b/src/init.luau @@ -52,35 +52,50 @@ local vide = { -- runtime step = function(dt: number) - -- debug.profilebegin("VIDE STEP") - -- debug.profilebegin("VIDE SPRING") + if game then + debug.profilebegin("VIDE STEP") + debug.profilebegin("VIDE SPRING") + end + update_springs(dt) - -- debug.profileend() - -- debug.profilebegin("VIDE GARBAGE CLEANUP") + + if game then + debug.profileend() + debug.profilebegin("VIDE GARBAGE CLEANUP") + end + clean_garbage() - -- debug.profileend() - -- debug.profileend() + + if game then + debug.profileend() + debug.profileend() + end end } -setmetatable(vide :: any, { - __index = function(_, index: unknown): () - if index == "strict" then - return flags.strict - else - throw(`{tostring(index)} is not a valid member of vide`) - end - end, +do + local set = false - __newindex = function(_, index: unknown, value: unknown) - if index == "strict" then - if value ~= true then throw "strict mode can only be set to true" end - flags.strict = true - else - throw(`{tostring(index)} is not a valid member of vide`) + setmetatable(vide :: any, { + __index = function(_, index: unknown): () + if index == "strict" then + return flags.strict + else + throw(`{tostring(index)} is not a valid member of vide`) + end + end, + + __newindex = function(_, index: unknown, value: unknown) + if index == "strict" then + if set then throw "strict mode has already been set" end + set = true + flags.strict = value :: boolean + else + throw(`{tostring(index)} is not a valid member of vide`) + end end - end -}) + }) +end if game then game:GetService("RunService").Heartbeat:Connect(function(dt: number) diff --git a/src/maps.luau b/src/maps.luau index a7c0f6d..977cfe2 100644 --- a/src/maps.luau +++ b/src/maps.luau @@ -86,7 +86,7 @@ local function indexes(input: () -> Map, transform: (() -> VI, return output_array end - local output, output_get = create(nil :: any) + local output, read_output_value = create(nil :: any) local function derive() return recompute(input()) @@ -108,7 +108,7 @@ local function indexes(input: () -> Map, transform: (() -> VI, end end) - return output_get + return read_output_value end -- todo: optimize output array @@ -182,7 +182,7 @@ local function values(input: () -> Map, transform: (VI, () -> return output_array end - local output, output_get = create(nil :: any) + local output, read_output_value = create(nil :: any) local function derive() return recompute(input()) @@ -205,7 +205,7 @@ local function values(input: () -> Map, transform: (VI, () -> end end) - return output_get + return read_output_value end return function() return indexes, values end diff --git a/src/memoize.luau b/src/memoize.luau index 448f1f9..cf83427 100644 --- a/src/memoize.luau +++ b/src/memoize.luau @@ -1,8 +1,8 @@ -local function memoize(f: (X) -> Y): ((X) -> Y, { [X]: Y }) - local cache: { [X]: Y } = {} +local function memoize(f: (X) -> Y): (X) -> Y + local cache: { [X]: Y? } = {} return function(x: X): Y - local y: Y? = cache[x] + local y = cache[x] if not y then y = f(x) @@ -10,7 +10,7 @@ local function memoize(f: (X) -> Y): ((X) -> Y, { [X]: Y }) end return y :: Y - end, cache + end end return memoize diff --git a/src/source.luau b/src/source.luau index 036aed3..d0da051 100644 --- a/src/source.luau +++ b/src/source.luau @@ -5,14 +5,13 @@ type Node = graph.Node local create = graph.create local set = graph.set - export type Source = (() -> T) & ((T) -> T) local function source(value: T): Source - local node, get_value = create(value :: T) + local node, read_node_value = create(value :: T) return function(...): T - if select("#", ...) == 0 then return get_value() end + if select("#", ...) == 0 then return read_node_value() end -- check if any args were given local v = ... :: T if node.cache == v and (type(v) ~= "table" or table.isfrozen(v)) then return v end diff --git a/src/spring.luau b/src/spring.luau index 041739e..5f97325 100644 --- a/src/spring.luau +++ b/src/spring.luau @@ -21,7 +21,6 @@ Unsupported datatypes: ]] local throw = require(script.Parent.throw) - local graph = require(script.Parent.graph) type Node = graph.Node local create = graph.create @@ -51,6 +50,7 @@ type SpringData = { type Lerp = (initial: T, target: T, alpha: number) -> T +-- period, damping ratio, initial velocity, total time local function solve(T: number, z: number, u: number, t: number): number -- alpha local wn = 2*math.pi / T local wd = wn * math.sqrt(1 - z^2) @@ -94,6 +94,8 @@ local lerpable: { [string]: Lerp } = { end :: Lerp, } +-- maps spring data to its corresponding output node +-- lifetime of spring data is tied to output node's local springs: { [SpringData]: Node } = {} setmetatable(springs, { __mode = "vs" }) @@ -103,7 +105,6 @@ local function spring(target: () -> T, period: number?, damping_ratio: number end local inputs, initial_position = capture(target) - local output, output_get = create(initial_position) local data: SpringData = { @@ -121,14 +122,16 @@ local function spring(target: () -> T, period: number?, damping_ratio: number target = target } - local function input_changed(node) + -- reschedule spring for simulation on input update + local function input_updated(node) data.target_updated = true data.target_position = target() springs[data] = node end + -- register above function as side-effect for all inputs for _, input in next, inputs do - set_effect(input, input_changed, output) + set_effect(input, input_updated, output) end springs[data] = output @@ -136,6 +139,8 @@ local function spring(target: () -> T, period: number?, damping_ratio: number return output_get end +-- `springs` is a hashmap, use array to queue indexes-to-remove to avoid +-- iterator invalidation of `springs` local remove_queue = {} local function update_springs(dt: number) @@ -183,7 +188,7 @@ local function update_springs(dt: number) local value = lerp(initial_position, target_position, new_alpha) if math.abs(1 - new_alpha) < TOLERANCE and math.abs(new_velocity) < TOLERANCE then - -- close enough to target, remove and set value to target + -- close enough to target, unshedule spring and set value to target table.insert(remove_queue, data) set(output, target_position) else diff --git a/src/throw.luau b/src/throw.luau index 41bc8cb..712cdc2 100644 --- a/src/throw.luau +++ b/src/throw.luau @@ -1,3 +1,5 @@ +-- returns path to file as an array with each directory +-- accounts for Roblox and Luau contexts local function get_path(s) if string.sub(s, #s - 4, #s) == ".luau" then s = string.sub(s, 1, #s - 5) @@ -6,11 +8,14 @@ local function get_path(s) return string.split(s, string.match(s, "%w+/") and "/" or ".") end +-- get directory of vide root local root do local path = get_path(debug.info(1, "s")) root = path[#path - 1] end +-- throws an error, ensuring stack trace begins at the first callsite outside +-- of all vide library files local function throw(msg: string) local stack = 1 diff --git a/src/untrack.luau b/src/untrack.luau index 4aa744f..ec1e774 100644 --- a/src/untrack.luau +++ b/src/untrack.luau @@ -6,10 +6,14 @@ local refs = graph.refs local function untrack(source: () -> T): T local initial = #refs + local value = source() + + -- remove any references made since `untrack()` was called for i = initial, #refs do refs[i] = nil end + return value end diff --git a/src/watch.luau b/src/watch.luau index beeae78..27233ca 100644 --- a/src/watch.luau +++ b/src/watch.luau @@ -7,13 +7,16 @@ local capture = graph.capture local function watch(effect: () -> ()): () -> () local nodes = capture(effect :: () -> nil) + -- store aside captured nodes in new table nodes = table.clone(nodes) + -- register effect with permanent lifetime for _, node in next, nodes do set_effect(node, effect, true) end local function unwatch() + -- unregister effect from all nodes for _, node in next, nodes do set_effect(node, effect, nil) end