diff --git a/modules/react-roblox/src/client/roblox/RobloxComponentProps.lua b/modules/react-roblox/src/client/roblox/RobloxComponentProps.lua index 5fa246e5..5b46ad5c 100644 --- a/modules/react-roblox/src/client/roblox/RobloxComponentProps.lua +++ b/modules/react-roblox/src/client/roblox/RobloxComponentProps.lua @@ -125,9 +125,7 @@ local function attachBinding(hostInstance, key, newBinding): () instanceToBindings[hostInstance] = {} end - instanceToBindings[hostInstance][key] = - React.__subscribeToBinding(newBinding, updateBoundProperty) - + instanceToBindings[hostInstance][key] = newBinding:_subscribe(updateBoundProperty) updateBoundProperty(newBinding:getValue()) end diff --git a/modules/react/src/React.lua b/modules/react/src/React.lua index 26722c44..bce665f5 100644 --- a/modules/react/src/React.lua +++ b/modules/react/src/React.lua @@ -133,18 +133,19 @@ return { -- ROBLOX TODO: REACT_SCOPE_TYPE as unstable_Scope, -- ROBLOX TODO: useOpaqueIdentifier as unstable_useOpaqueIdentifier, - -- ROBLOX deviation START: bindings support + -- ROBLOX DEVIATION START: bindings support + __subscribeToBinding = @[deprecated{ use = "ReactBinding:_subscribe()" }] function( + binding: ReactBinding, + f: (value: T) -> () + ): () -> () + return binding:_subscribe() + end, createBinding = ReactBinding.create, joinBindings = ReactBinding.join, - -- ROBLOX deviation END + -- ROBLOX DEVIATION END -- ROBLOX DEVIATION: export the `None` placeholder for use with setState None = ReactNone, - - -- ROBLOX FIXME: These aren't supposed to be exposed, but they're needed by - -- the renderer in order to update properly - __subscribeToBinding = ReactBinding.subscribe, - -- ROBLOX DEVIATION: export Change, Event, and Tag from React Event = require(Packages.Shared).Event, Change = require(Packages.Shared).Change, diff --git a/modules/react/src/ReactBinding.roblox.lua b/modules/react/src/ReactBinding.roblox.lua index db15415c..e26ad5f6 100644 --- a/modules/react/src/ReactBinding.roblox.lua +++ b/modules/react/src/ReactBinding.roblox.lua @@ -17,217 +17,213 @@ local Packages = script.Parent.Parent local ReactGlobals = require(Packages.ReactGlobals) -local LuauPolyfill = require(Packages.LuauPolyfill) -local ReactSymbols = require(Packages.Shared).ReactSymbols - -local ReactTypes = require(Packages.Shared) -type Binding = ReactTypes.ReactBinding -type BindingUpdater = ReactTypes.ReactBindingUpdater - -local Symbol = LuauPolyfill.Symbol -local createSignal = require(script.Parent["createSignal.roblox"]) - -local BindingImpl = Symbol("BindingImpl") - -type BindingInternal = { - ["$$typeof"]: typeof(ReactSymbols.REACT_BINDING_TYPE), - value: T, - - getValue: (BindingInternal) -> T, - -- FIXME Luau: can't define recursive types with different parameters - map: (BindingInternal, (T) -> U) -> any, - - update: (T) -> (), - subscribe: ((T) -> ()) -> () -> (), -} +local Shared = require(Packages.Shared) +local ReactSymbols = Shared.ReactSymbols +local ReactTypes = Shared + +local function IS_BINDING(value: unknown): boolean + -- stylua: ignore + return type(value) == "table" + and value["$$typeof"] == ReactSymbols.REACT_BINDING_TYPE +end -local BindingInternalApi = {} +local ReactBinding = {} -local bindingPrototype = {} +-- stylua: ignore +local bindingPrototype = {} do + bindingPrototype["$$typeof"] = ReactSymbols.REACT_BINDING_TYPE + bindingPrototype.__index = bindingPrototype -function bindingPrototype.getValue(binding: BindingInternal): T - return BindingInternalApi.getValue(binding) -end + function bindingPrototype.__tostring(binding) + return `RoactBinding({binding:getValue()})` + end -function bindingPrototype.map( - binding: BindingInternal, - predicate: (T) -> U -): Binding - return BindingInternalApi.map(binding, predicate) -end + function bindingPrototype._subscribe(binding, callback) + local callbacks = binding._callbacks + local callbackState = callbacks[callback] -local BindingPublicMeta = { - __index = bindingPrototype, - __tostring = function(self) - return string.format("RoactBinding(%s)", tostring(self:getValue())) - end, -} + if binding._firing and callbackState then + callbacks[callback] = false + elseif callbackState == nil then + callbacks[callback] = true + end -function BindingInternalApi.update(binding: any, newValue: T) - return (binding[BindingImpl] :: BindingInternal).update(newValue) -end + return function() + callbacks[callback] = nil + end + end -function BindingInternalApi.subscribe(binding: any, callback: (T) -> ()) - return (binding[BindingImpl] :: BindingInternal).subscribe(callback) -end + function bindingPrototype.getValue(binding) + return binding._value + end -function BindingInternalApi.getValue(binding: any): T - return (binding[BindingImpl] :: BindingInternal):getValue() -end + function ReactBinding.create(initialValue: T): ( + ReactTypes.ReactBinding, + ReactTypes.ReactBindingUpdater + ) + local callbacks = {} + local source -function BindingInternalApi.create(initialValue: T): (Binding, BindingUpdater) - local subscribe, fire = createSignal() - local impl = { - value = initialValue, - subscribe = subscribe, - } + if ReactGlobals.__DEV__ then + -- ROBLOX TODO: LUAFDN-619 - improve debug stacktraces for bindings + source = debug.traceback("Binding created at:", 3) + end - function impl.update(newValue: T) - impl.value = newValue - fire(newValue) - end + local binding = { + _callbacks = callbacks, + _value = initialValue, + _source = source, + _firing = false, + } + + local function update(newValue: T) + binding._value = newValue + + binding._firing = true + for callback, notSuspended in callbacks do + if notSuspended then + callback(newValue) + else + callbacks[callback] = false + end + end + binding._firing = false + end - function impl.getValue() - return impl.value - end + binding.update = update - local source - if ReactGlobals.__DEV__ then - -- ROBLOX TODO: LUAFDN-619 - improve debug stacktraces for bindings - source = debug.traceback("Binding created at:", 3) + return setmetatable(binding, bindingPrototype) :: any, update end - - return (setmetatable({ - ["$$typeof"] = ReactSymbols.REACT_BINDING_TYPE, - [BindingImpl] = impl, - _source = source, - }, BindingPublicMeta) :: any) :: Binding, - impl.update end -function BindingInternalApi.map( - upstreamBinding: BindingInternal, - predicate: (T) -> U -): Binding - if ReactGlobals.__DEV__ then - -- ROBLOX TODO: More informative error messages here - assert( - typeof(upstreamBinding) == "table" - and upstreamBinding["$$typeof"] == ReactSymbols.REACT_BINDING_TYPE, - "Expected `self` to be a binding" - ) - assert(typeof(predicate) == "function", "Expected arg #1 to be a function") +do -- map binding + local mappedBindingPrototype = setmetatable({}, bindingPrototype) + mappedBindingPrototype.__index = mappedBindingPrototype + + function mappedBindingPrototype.getValue(mappedBinding) + return mappedBinding._predicate(mappedBinding._upstreamBinding:getValue()) end - local impl = {} + function mappedBindingPrototype._subscribe(mappedBinding, callback) + local predicate = mappedBinding._predicate - function impl.subscribe(callback) - return BindingInternalApi.subscribe(upstreamBinding, function(newValue) + return mappedBinding._upstreamBinding:_subscribe(function(newValue) callback(predicate(newValue)) end) end - function impl.update(newValue) - error("Bindings created by Binding:map(fn) cannot be updated directly", 2) + function mappedBindingPrototype.update() + error("Bindings created by ReactBinding:map() cannot be updated directly", 2) end - function impl.getValue() - return predicate(upstreamBinding:getValue()) - end - - local source - if ReactGlobals.__DEV__ then - -- ROBLOX TODO: LUAFDN-619 - improve debug stacktraces for bindings - source = debug.traceback("Mapped binding created at:", 3) - end + local function mapBinding( + upstreamBinding: ReactTypes.ReactBinding, + predicate: (T) -> U + ): ReactTypes.ReactBinding + local source + + if ReactGlobals.__DEV__ then + -- ROBLOX TODO: More informative error messages here + assert( + IS_BINDING(upstreamBinding), + "Expected 'upstreamBinding' to be of type 'ReactBinding'" + ) + assert(type(predicate) == "function", "Expected 'predicate' to be of type function") + + -- ROBLOX TODO: LUAFDN-619 - improve debug stacktraces for bindings + source = debug.traceback("Mapped Binding created at:", 3) + end - return ( - setmetatable({ - ["$$typeof"] = ReactSymbols.REACT_BINDING_TYPE, - [BindingImpl] = impl, + return setmetatable({ + _upstreamBinding = upstreamBinding, + _predicate = predicate, _source = source, - }, BindingPublicMeta) :: any - ) :: Binding -end - --- The `join` API is used statically, so the input will be a table with values --- typed as the public Binding type -function BindingInternalApi.join( - upstreamBindings: { [string | number]: Binding } -): Binding - if ReactGlobals.__DEV__ then - assert(typeof(upstreamBindings) == "table", "Expected arg #1 to be of type table") - - for key, value in upstreamBindings do - if - typeof(value) ~= "table" - or (value :: any)["$$typeof"] ~= ReactSymbols.REACT_BINDING_TYPE - then - local message = ("Expected arg #1 to contain only bindings, but key %q had a non-binding value"):format( - tostring(key) - ) - error(message, 2) - end - end + }, mappedBindingPrototype) :: any end - local impl = {} - local function getValue() + bindingPrototype.map = mapBinding + ReactBinding.map = mapBinding + table.freeze(mappedBindingPrototype) + table.freeze(bindingPrototype) +end + +do -- join + local function getValueJoined( + upstreamBindings: { [string | number]: ReactTypes.ReactBinding } + ): { [string | number]: any } local value = {} - -- ROBLOX FIXME Luau: needs CLI-56711 resolved to eliminate ipairs() - for key, upstream in pairs(upstreamBindings) do + for key, upstream in upstreamBindings do value[key] = upstream:getValue() end return value end - function impl.subscribe(callback) - -- ROBLOX FIXME: type refinements - local disconnects: any = {} + local joinedBindingPrototype = setmetatable({}, bindingPrototype) + joinedBindingPrototype.__index = joinedBindingPrototype + + function joinedBindingPrototype.getValue(joinedBinding) + return getValueJoined(joinedBinding._upstreamBindings) + end + + function joinedBindingPrototype._subscribe(joinedBinding, callback) + local upstreamBindings = joinedBinding._upstreamBindings + local disconnects = {} :: { () -> () } for key, upstream in upstreamBindings do - disconnects[key] = BindingInternalApi.subscribe(upstream, function(newValue) - callback(getValue()) - end) + table.insert(disconnects, upstream:_subscribe(function(newValue) + callback(getValueJoined(upstreamBindings)) + end)) end return function() - if disconnects == nil then - return - end - for _, disconnect in disconnects do disconnect() end - - disconnects = nil end end - function impl.update(newValue) - error("Bindings created by joinBindings(...) cannot be updated directly", 2) + function joinedBindingPrototype.update() + error("Bindings created by React.joinBindings() cannot be updated directly", 2) end - function impl.getValue() - return getValue() - end + -- The `join` API is used statically, so the input will be a table with values + -- typed as the public Binding type + function ReactBinding.join( + upstreamBindings: { [string | number]: ReactTypes.ReactBinding } + ): ReactTypes.ReactBinding + local source + + if ReactGlobals.__DEV__ then + assert( + type(upstreamBindings) == "table", + "Expected 'upstreamBindings' to be of type table" + ) + + for key, value in upstreamBindings do + if IS_BINDING(value) then + continue + end + + error( + `Expected table 'upstreamBindings' to contain only bindings, but key "{key}" had a non-binding value`, + 2 + ) + end - local source - if ReactGlobals.__DEV__ then - -- ROBLOX TODO: LUAFDN-619 - improve debug stacktraces for bindings - source = debug.traceback("Joined binding created at:", 2) - end + -- ROBLOX TODO: LUAFDN-619 - improve debug stacktraces for bindings + source = debug.traceback("Joined Binding created at:", 2) + end - return ( - setmetatable({ - ["$$typeof"] = ReactSymbols.REACT_BINDING_TYPE, - [BindingImpl] = impl, + return setmetatable({ + _upstreamBindings = upstreamBindings, _source = source, - }, BindingPublicMeta) :: any - ) :: Binding + }, joinedBindingPrototype) :: any + end + + table.freeze(joinedBindingPrototype) end -return BindingInternalApi +return table.freeze(ReactBinding) diff --git a/modules/react/src/__tests__/createSignal.spec.lua b/modules/react/src/__tests__/createSignal.spec.lua deleted file mode 100644 index 1072a581..00000000 --- a/modules/react/src/__tests__/createSignal.spec.lua +++ /dev/null @@ -1,101 +0,0 @@ -local createSignal = require(script.Parent.Parent["createSignal.roblox"]) - -local Packages = script.Parent.Parent.Parent -local JestGlobals = require(Packages.Dev.JestGlobals) -local jestExpect = JestGlobals.expect -local jest = JestGlobals.jest - -local it = JestGlobals.it - -it("should fire subscribers and disconnect them", function() - local subscribe, fire = createSignal() - - local spy = jest.fn() - local disconnect = subscribe(function(...) - spy(...) - end) - - jestExpect(spy).never.toBeCalled() - - local a = 1 - local b = {} - local c = "hello" - fire(a, b, c) - - jestExpect(spy).toBeCalledTimes(1) - jestExpect(spy).toBeCalledWith(a, b, c) - - disconnect() - - fire() - - jestExpect(spy).toBeCalledTimes(1) -end) - -it("should handle multiple subscribers", function() - local subscribe, fire = createSignal() - - local spyA = jest.fn() - local spyB = jest.fn() - - local disconnectA = subscribe(function(...) - spyA(...) - end) - local disconnectB = subscribe(function(...) - spyB(...) - end) - - jestExpect(spyA).never.toBeCalled() - jestExpect(spyB).never.toBeCalled() - - local a = {} - local b = 67 - fire(a, b) - - jestExpect(spyA).toBeCalledTimes(1) - jestExpect(spyA).toBeCalledWith(a, b) - - jestExpect(spyB).toBeCalledTimes(1) - jestExpect(spyB).toBeCalledWith(a, b) - - disconnectA() - - fire(b, a) - - jestExpect(spyA).toBeCalledTimes(1) - - jestExpect(spyB).toBeCalledTimes(2) - jestExpect(spyB).toBeCalledWith(b, a) - - disconnectB() -end) - -it("should stop firing a connection if disconnected mid-fire", function() - local subscribe, fire = createSignal() - - -- In this test, we'll connect two listeners that each try to disconnect - -- the other. Because the order of listeners firing isn't defined, we - -- have to be careful to handle either case. - - local disconnectA - local disconnectB - - local spyA = jest.fn(function() - disconnectB() - end) - - local spyB = jest.fn(function() - disconnectA() - end) - - disconnectA = subscribe(function(...) - spyA(...) - end) - disconnectB = subscribe(function(...) - spyB(...) - end) - - fire() - - jestExpect(#spyA.mock.calls + #spyB.mock.calls).toBe(1) -end) diff --git a/modules/react/src/createSignal.roblox.lua b/modules/react/src/createSignal.roblox.lua deleted file mode 100644 index 3580a2e2..00000000 --- a/modules/react/src/createSignal.roblox.lua +++ /dev/null @@ -1,91 +0,0 @@ ---!strict --- ROBLOX upstream: https://github.com/Roblox/roact/blob/master/src/createSignal.lua ---[[ - * Copyright (c) Roblox Corporation. All rights reserved. - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. -]] - -type Function = (...any) -> ...any ---[[ - This is a simple signal implementation that has a dead-simple API. - - local signal = createSignal() - - local disconnect = signal:subscribe(function(foo) - print("Cool foo:", foo) - end) - - signal:fire("something") - - disconnect() -]] - -type Connection = { callback: Function, disconnected: boolean } -type Map = { [K]: V } - -local function createSignal(): ((Function) -> () -> (), (...any) -> ()) - local connections: Map = {} - local suspendedConnections = {} - local firing = false - - local function subscribe(callback) - assert( - typeof(callback) == "function", - "Can only subscribe to signals with a function." - ) - - local connection = { - callback = callback, - disconnected = false, - } - - -- If the callback is already registered, don't add to the suspendedConnection. Otherwise, this will disable - -- the existing one. - if firing and not connections[callback] then - suspendedConnections[callback] = connection - end - - connections[callback] = connection - - local function disconnect() - assert( - not connection.disconnected, - "Listeners can only be disconnected once." - ) - - connection.disconnected = true - connections[callback] = nil - suspendedConnections[callback] = nil - end - - return disconnect - end - - local function fire(...) - firing = true - for callback, connection in connections do - if not connection.disconnected and not suspendedConnections[callback] then - callback(...) - end - end - - firing = false - - -- ROBLOX performance: use table.clear - table.clear(suspendedConnections) - end - - return subscribe, fire -end - -return createSignal diff --git a/modules/shared/src/ReactTypes.lua b/modules/shared/src/ReactTypes.lua index 32509407..c25c8a83 100644 --- a/modules/shared/src/ReactTypes.lua +++ b/modules/shared/src/ReactTypes.lua @@ -174,19 +174,32 @@ export type ReactScopeInstance = { -- FIXME Luau: can't create recursive type with different parameters, so we -- need to split the generic `map` method into a different type and then -- re-combine those types together -type CoreReactBinding = { - getValue: (self: CoreReactBinding) -> T, - _source: string?, +type ReactBindingPrototype = { + getValue: (self: ReactBindingPrototype) -> T, + _subscribe: ( + self: ReactBindingPrototype, + callback: (newValue: T) -> () + ) -> () -> (), + __tostring: (self: ReactBindingPrototype) -> string, + __index: ReactBindingPrototype, + ["$$typeof"]: any, -- userdatas from newproxy() are typed any } -type ReactBindingMap = { + +type ReactBindingPrototypeMap = { map: ( - self: CoreReactBinding & ReactBindingMap, - (T) -> U - ) -> ReactBindingMap & CoreReactBinding, + self: ReactBindingPrototypeMap & ReactBindingPrototype, + predicate: (value: T) -> U + ) -> ReactBindingPrototypeMap & ReactBindingPrototype, } -export type ReactBinding = CoreReactBinding & ReactBindingMap -export type ReactBindingUpdater = (T) -> () +-- stylua: ignore +export type ReactBinding = + & { + _source: string?, + } + & ReactBindingPrototypeMap + & ReactBindingPrototype +export type ReactBindingUpdater = (newValue: T) -> () -- ROBLOX deviation END -- Mutable source version can be anything (e.g. number, string, immutable data structure)