diff --git a/default.project.json b/default.project.json index d6dae0f4..fc4e3265 100644 --- a/default.project.json +++ b/default.project.json @@ -5,6 +5,15 @@ "React": { "$path": "packages/react/default.project.json" }, + "ReactDebugTools": { + "$path": "packages/react-debug-tools/default.project.json" + }, + "ReactDevtoolsShared": { + "$path": "packages/react-devtools-shared/default.project.json" + }, + "ReactIs": { + "$path": "packages/react-is/default.project.json" + }, "ReactReconciler": { "$path": "packages/react-reconciler/default.project.json" }, diff --git a/packages/react-debug-tools/default.project.json b/packages/react-debug-tools/default.project.json new file mode 100644 index 00000000..3654416a --- /dev/null +++ b/packages/react-debug-tools/default.project.json @@ -0,0 +1,6 @@ +{ + "name": "react-debug-tools", + "tree": { + "$path": "src/" + } +} \ No newline at end of file diff --git a/packages/react-debug-tools/src/ReactDebugHooks.lua b/packages/react-debug-tools/src/ReactDebugHooks.lua new file mode 100644 index 00000000..d18870f1 --- /dev/null +++ b/packages/react-debug-tools/src/ReactDebugHooks.lua @@ -0,0 +1,1185 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.2/packages/react-debug-tools/src/ReactDebugHooks.js +--[[* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + ]] +type void = nil --[[ ROBLOX FIXME: adding `void` type alias to make it easier to use Luau `void` equivalent when supported ]] +local Packages = script.Parent.Parent +local LuauPolyfill = require(Packages.LuauPolyfill) +local Array = LuauPolyfill.Array +-- ROBLOX deviation START: not needed +-- local Boolean = LuauPolyfill.Boolean +-- ROBLOX deviation END +local Error = LuauPolyfill.Error +local Map = LuauPolyfill.Map +local Object = LuauPolyfill.Object +type Array = LuauPolyfill.Array +type Error = LuauPolyfill.Error +type Map = LuauPolyfill.Map +-- ROBLOX deviation START: add additional imports +type Object = LuauPolyfill.Object +local String = LuauPolyfill.String +-- ROBLOX deviation END +local exports = {} +-- local sharedReactTypesModule = require(Packages.shared.ReactTypes) +-- type MutableSource = sharedReactTypesModule.MutableSource +-- type MutableSourceGetSnapshotFn = sharedReactTypesModule.MutableSourceGetSnapshotFn +-- type MutableSourceSubscribeFn = sharedReactTypesModule.MutableSourceSubscribeFn +-- type ReactContext = sharedReactTypesModule.ReactContext +-- type ReactProviderType = sharedReactTypesModule.ReactProviderType +local ReactTypes = require(Packages.Shared) +type MutableSource = ReactTypes.MutableSource +type MutableSourceGetSnapshotFn = ReactTypes.MutableSourceGetSnapshotFn +type MutableSourceSubscribeFn = ReactTypes.MutableSourceSubscribeFn +type ReactContext = ReactTypes.ReactContext +type ReactProviderType = ReactTypes.ReactProviderType + +-- ROBLOX deviation END +-- ROBLOX deviation START: add import type that is a built-in in flow +type React_Node = ReactTypes.React_Node +-- ROBLOX deviation END + +-- ROBLOX deviation START: add binding support +type ReactBinding = ReactTypes.ReactBinding +type ReactBindingUpdater = ReactTypes.ReactBindingUpdater +-- ROBLOX deviation END +-- ROBLOX deviation START: fix import +-- local reactReconcilerSrcReactInternalTypesModule = +-- require(Packages["react-reconciler"].src.ReactInternalTypes) +local reactReconcilerSrcReactInternalTypesModule = require(Packages.ReactReconciler) +-- ROBLOX deviation END +type Fiber = reactReconcilerSrcReactInternalTypesModule.Fiber +type DispatcherType = reactReconcilerSrcReactInternalTypesModule.Dispatcher +-- ROBLOX deviation START: fix import - import from Shared +-- local reactReconcilerSrcReactFiberHostConfigModule = +-- require(Packages["react-reconciler"].src.ReactFiberHostConfig) +local reactReconcilerSrcReactFiberHostConfigModule = require(Packages.Shared) +-- ROBLOX deviation END +type OpaqueIDType = reactReconcilerSrcReactFiberHostConfigModule.OpaqueIDType +-- ROBLOX deviation START: fix import +-- local NoMode = require(Packages["react-reconciler"].src.ReactTypeOfMode).NoMode +local ReconcilerModule = require(Packages.ReactReconciler)({}) +local NoMode = ReconcilerModule.ReactTypeOfMode.NoMode +-- ROBLOX deviation END +-- ROBLOX deviation START: add inline ErrorStackParser implementation +-- local ErrorStackParser = require(Packages["error-stack-parser"]).default +type StackFrame = { + source: string?, + functionName: string?, +} +local ErrorStackParser = { + parse = function(error_: Error): Array + if error_.stack == nil then + return {} + end + local filtered = Array.filter(string.split(error_.stack :: string, "\n"), function(line) + return string.find(line, "^LoadedCode") ~= nil + end) + return Array.map(filtered, function(stackTraceLine) + -- ROBLOX FIXME Luau: shouldn't need to explicitly provide nilable field + local functionName = string.match(stackTraceLine, "function (%w+)$") + return { source = stackTraceLine, functionName = functionName } + end) + end, +} +-- ROBLOX deviation END +-- ROBLOX deviation START: import from Shared +-- local ReactSharedInternals = require(Packages.shared.ReactSharedInternals).default +-- local REACT_OPAQUE_ID_TYPE = require(Packages.shared.ReactSymbols).REACT_OPAQUE_ID_TYPE +local SharedModule = require(Packages.Shared) +local ReactSharedInternals = SharedModule.ReactSharedInternals +local ReactSymbols = SharedModule.ReactSymbols +local REACT_OPAQUE_ID_TYPE = ReactSymbols.REACT_OPAQUE_ID_TYPE +-- ROBLOX deviation END +-- ROBLOX deviation START: fix import - get from ReconcilerModule +-- local reactReconcilerSrcReactWorkTagsModule = +-- require(Packages["react-reconciler"].src.ReactWorkTags) +local reactReconcilerSrcReactWorkTagsModule = ReconcilerModule.ReactWorkTags +-- ROBLOX deviation END +local FunctionComponent = reactReconcilerSrcReactWorkTagsModule.FunctionComponent +local SimpleMemoComponent = reactReconcilerSrcReactWorkTagsModule.SimpleMemoComponent +local ContextProvider = reactReconcilerSrcReactWorkTagsModule.ContextProvider +local ForwardRef = reactReconcilerSrcReactWorkTagsModule.ForwardRef +local Block = reactReconcilerSrcReactWorkTagsModule.Block +-- ROBLOX deviation START: fix import +-- type CurrentDispatcherRef = typeof(ReactSharedInternals_ReactCurrentDispatcher) -- Used to track hooks called during a render +type CurrentDispatcherRef = typeof(ReactSharedInternals.ReactCurrentDispatcher) +-- ROBLOX deviation END +type HookLogEntry = { primitive: string, stackError: Error, value: unknown } --[[ ROBLOX CHECK: inexact type upstream which is not supported by Luau. Verify if it doesn't break the analyze ]] +local hookLog: Array = {} -- Primitives +type BasicStateAction = (S) -> S | S +type Dispatch = (A) -> () +local primitiveStackCache: nil --[[ ROBLOX CHECK: verify if `null` wasn't used differently than `undefined` ]] | Map> = + nil +local currentFiber: Fiber | nil --[[ ROBLOX CHECK: verify if `null` wasn't used differently than `undefined` ]] = nil +type Hook = { + memoizedState: any, + next: Hook | nil,--[[ ROBLOX CHECK: verify if `null` wasn't used differently than `undefined` ]] +} +-- ROBLOX deviation START: add predefined variable +local Dispatcher: DispatcherType +-- ROBLOX deviation END +local function getPrimitiveStackCache(): Map> + -- This initializes a cache of all primitive hooks so that the top + -- most stack frames added by calling the primitive hook can be removed. + if primitiveStackCache == nil then + local cache = Map.new() + local readHookLog + do --[[ ROBLOX COMMENT: try-finally block conversion ]] + -- ROBLOX deviation START: doesn't return + -- local ok, result, hasReturned = pcall(function() + local ok, result = pcall(function() + -- ROBLOX deviation END + -- Use all hooks here to add them to the hook log. + -- ROBLOX deviation START: use dot notation + -- Dispatcher:useContext({ _currentValue = nil } :: any) + -- Dispatcher:useState(nil) + -- Dispatcher:useReducer(function(s, a) + Dispatcher.useContext({ _currentValue = nil } :: any) + Dispatcher.useState(nil) + Dispatcher.useReducer(function(s, a) + -- ROBLOX deviation END + return s + end, nil) + -- ROBLOX deviation START: use dot notation + -- Dispatcher:useRef(nil) + -- Dispatcher:useLayoutEffect(function() end) + -- Dispatcher:useEffect(function() end) + -- Dispatcher:useImperativeHandle(nil, function() + Dispatcher.useRef(nil) + Dispatcher.useLayoutEffect(function() end) + Dispatcher.useEffect(function() end) + Dispatcher.useImperativeHandle(nil, function() + -- ROBLOX deviation END + return nil + end) + -- ROBLOX deviation START: use dot notation + -- Dispatcher:useDebugValue(nil) + -- Dispatcher:useCallback(function() end) + -- Dispatcher:useMemo(function() + Dispatcher.useDebugValue(nil) + Dispatcher.useCallback(function() end) + Dispatcher.useMemo(function() + -- ROBLOX deviation END + return nil + end) + end) + do + readHookLog = hookLog + hookLog = {} + end + -- ROBLOX deviation START: doesn't return + -- if hasReturned then + -- return result + -- end + -- ROBLOX deviation END + if not ok then + error(result) + end + end + -- ROBLOX deviation START: use for in loop instead of while + -- do + -- local i = 0 + -- while + -- i + -- < readHookLog.length --[[ ROBLOX CHECK: operator '<' works only if either both arguments are strings or both are a number ]] + -- do + -- local hook = readHookLog[tostring(i)] + -- cache:set(hook.primitive, ErrorStackParser:parse(hook.stackError)) + -- i += 1 + -- end + -- end + for i = 1, #readHookLog do + local hook = readHookLog[i] + cache:set(hook.primitive, ErrorStackParser.parse(hook.stackError)) + end + -- ROBLOX deviation END + primitiveStackCache = cache + end + -- ROBLOX deviation START: needs cast + -- return primitiveStackCache + return primitiveStackCache :: Map> + -- ROBLOX deviation END +end +local currentHook: nil --[[ ROBLOX CHECK: verify if `null` wasn't used differently than `undefined` ]] | Hook = nil +local function nextHook(): nil --[[ ROBLOX CHECK: verify if `null` wasn't used differently than `undefined` ]] | Hook + local hook = currentHook + if hook ~= nil then + currentHook = hook.next + end + return hook +end +local function readContext(context: ReactContext, observedBits: void | number | boolean): T + -- For now we don't expose readContext usage in the hooks debugging info. + return context._currentValue +end +local function useContext(context: ReactContext, observedBits: void | number | boolean): T + table.insert(hookLog, { primitive = "Context", stackError = Error.new(), value = context._currentValue }) --[[ ROBLOX CHECK: check if 'hookLog' is an Array ]] + return context._currentValue +end +-- ROBLOX deviation START: return 2 values instead of a tuple +-- local function useState( +-- initialState: () -> S | S +-- ): any --[[ ROBLOX TODO: Unhandled node for type: TupleTypeAnnotation ]] --[[ [S, Dispatch>] ]] +local function useState(initialState: (() -> S) | S): (S, Dispatch>) + -- ROBLOX deviation END + local hook = nextHook() + local state: S = if hook ~= nil + then hook.memoizedState + else if typeof(initialState) == "function" + then -- $FlowFixMe: Flow doesn't like mixed types + initialState() + else initialState + table.insert(hookLog, { primitive = "State", stackError = Error.new(), value = state }) --[[ ROBLOX CHECK: check if 'hookLog' is an Array ]] + -- ROBLOX deviation START: return 2 values instead of a tuple + -- return { state, function(action: BasicStateAction) end } + return state, function(action: BasicStateAction) end + -- ROBLOX deviation END +end +-- ROBLOX deviation START: return 2 values instead of a tuple +-- local function useReducer( +-- reducer: (S, A) -> S, +-- initialArg: I, +-- init: ((I) -> S)? +-- ): any --[[ ROBLOX TODO: Unhandled node for type: TupleTypeAnnotation ]] --[[ [S, Dispatch] ]] +local function useReducer(reducer: (S, A) -> S, initialArg: I, init: ((I) -> S)?): (S, Dispatch) + -- ROBLOX deviation END + local hook = nextHook() + local state + if hook ~= nil then + state = hook.memoizedState + else + state = if init ~= nil then init(initialArg) else (initialArg :: any) :: S + end + table.insert(hookLog, { primitive = "Reducer", stackError = Error.new(), value = state }) --[[ ROBLOX CHECK: check if 'hookLog' is an Array ]] + -- ROBLOX deviation START: return 2 values instead of a tuple + -- return { state, function(action: A) end } + return state, function(action: A) end + -- ROBLOX deviation END +end +-- ROBLOX deviation START: TS models this slightly differently, which is needed to have an initially empty ref and clear the ref, and still typecheck +-- local function useRef(initialValue: T): { current: T } +local function useRef(initialValue: T): { current: T | nil } + -- ROBLOX deviation END + local hook = nextHook() + local ref = if hook ~= nil then hook.memoizedState else { current = initialValue } + table.insert(hookLog, { primitive = "Ref", stackError = Error.new(), value = ref.current }) --[[ ROBLOX CHECK: check if 'hookLog' is an Array ]] + return ref +end +-- ROBLOX deviation START: add binding support; these aren't fully working hooks, so this +-- is just an approximation modeled off of the `ref` hook above +local function useBinding(initialValue: T): (ReactBinding, ReactBindingUpdater) + local hook = nextHook() + local binding = if hook ~= nil + then hook.memoizedState + else ({ + getValue = function(_self) + return initialValue + end, + } :: any) :: ReactBinding + + table.insert(hookLog, { + primitive = "Binding", + stackError = Error.new(), + value = binding:getValue(), + }) + + return binding, function(_value) end +end +-- ROBLOX deviation END +local function useLayoutEffect( + -- ROBLOX deviation START: Luau needs union type packs for this type to translate idiomatically + -- create: () -> () -> () | void, + create: (() -> ()) | (() -> (() -> ())), + -- ROBLOX deviation END + inputs: Array | void | nil --[[ ROBLOX CHECK: verify if `null` wasn't used differently than `undefined` ]] +): () + nextHook() + table.insert(hookLog, { primitive = "LayoutEffect", stackError = Error.new(), value = create }) --[[ ROBLOX CHECK: check if 'hookLog' is an Array ]] +end +local function useEffect( + -- ROBLOX deviation START: Luau needs union type packs for this type to translate idiomatically + -- create: () -> () -> () | void, + create: (() -> ()) | (() -> (() -> ())), + -- ROBLOX deviation END + inputs: Array | void | nil --[[ ROBLOX CHECK: verify if `null` wasn't used differently than `undefined` ]] +): () + nextHook() + table.insert(hookLog, { primitive = "Effect", stackError = Error.new(), value = create }) --[[ ROBLOX CHECK: check if 'hookLog' is an Array ]] +end +local function useImperativeHandle( + ref: { + current: T | nil,--[[ ROBLOX CHECK: verify if `null` wasn't used differently than `undefined` ]] + } | ( + inst: T | nil --[[ ROBLOX CHECK: verify if `null` wasn't used differently than `undefined` ]] + ) -> unknown | nil --[[ ROBLOX CHECK: verify if `null` wasn't used differently than `undefined` ]] | void, + create: () -> T, + inputs: Array | void | nil --[[ ROBLOX CHECK: verify if `null` wasn't used differently than `undefined` ]] +): () + nextHook() -- We don't actually store the instance anywhere if there is no ref callback + -- and if there is a ref callback it might not store it but if it does we + -- have no way of knowing where. So let's only enable introspection of the + -- ref itself if it is using the object form. + local instance = nil + if ref ~= nil and typeof(ref) == "table" then + instance = ref.current + end + table.insert(hookLog, { primitive = "ImperativeHandle", stackError = Error.new(), value = instance }) --[[ ROBLOX CHECK: check if 'hookLog' is an Array ]] +end +-- ROBLOX deviation START: add generic params +-- local function useDebugValue(value: any, formatterFn: ((value: any) -> any)?) +local function useDebugValue(value: T, formatterFn: ((value: T) -> any)?): () + -- ROBLOX deviation END + table.insert(hookLog, { + primitive = "DebugValue", + stackError = Error.new(), + value = if typeof(formatterFn) == "function" then formatterFn(value) else value, + }) --[[ ROBLOX CHECK: check if 'hookLog' is an Array ]] +end +local function useCallback( + callback: T, + inputs: Array | void | nil --[[ ROBLOX CHECK: verify if `null` wasn't used differently than `undefined` ]] +): T + local hook = nextHook() + table.insert(hookLog, { + primitive = "Callback", + stackError = Error.new(), + value = if hook ~= nil + then hook.memoizedState[ + 1 --[[ ROBLOX adaptation: added 1 to array index ]] + ] + else callback, + }) --[[ ROBLOX CHECK: check if 'hookLog' is an Array ]] + return callback +end +-- ROBLOX deviation START: FIXME Luau: work around 'Failed to unify type packs' error: CLI-51338 +-- local function useMemo( +-- nextCreate: () -> T, +-- inputs: Array | void | nil --[[ ROBLOX CHECK: verify if `null` wasn't used differently than `undefined` ]] +-- ): T +local function useMemo(nextCreate: () -> T..., inputs: Array | nil): ...any + -- ROBLOX deviation END + local hook = nextHook() + -- ROBLOX deviation START: Wrap memoized values in a table and unpack to allow for multiple return values + -- local value = if hook ~= nil + -- then hook.memoizedState[ + -- 1 --[[ ROBLOX adaptation: added 1 to array index ]] + -- ] + -- else nextCreate() + local value = if hook ~= nil then hook.memoizedState[1] else { nextCreate() } + -- ROBLOX deviation END + + table.insert(hookLog, { primitive = "Memo", stackError = Error.new(), value = value }) --[[ ROBLOX CHECK: check if 'hookLog' is an Array ]] + -- ROBLOX deviation START: unwrap memoized values in a table + -- return value + return table.unpack(value) + -- ROBLOX deviation END +end +local function useMutableSource( + source: MutableSource, + getSnapshot: MutableSourceGetSnapshotFn, + subscribe: MutableSourceSubscribeFn +): Snapshot + -- useMutableSource() composes multiple hooks internally. + -- Advance the current hook index the same number of times + -- so that subsequent hooks have the right memoized state. + nextHook() -- MutableSource + nextHook() -- State + nextHook() -- Effect + nextHook() -- Effect + local value = getSnapshot(source._source) + table.insert(hookLog, { primitive = "MutableSource", stackError = Error.new(), value = value }) --[[ ROBLOX CHECK: check if 'hookLog' is an Array ]] + return value +end +-- ROBLOX deviation START: enable these once they are fully enabled in the Dispatcher type and in ReactFiberHooks' myriad dispatchers +-- local function useTransition( +-- ): any --[[ ROBLOX TODO: Unhandled node for type: TupleTypeAnnotation ]] --[[ [(() => void) => void, boolean] ]] +-- -- useTransition() composes multiple hooks internally. +-- -- Advance the current hook index the same number of times +-- -- so that subsequent hooks have the right memoized state. +-- nextHook() -- State +-- nextHook() -- Callback +-- table.insert( +-- hookLog, +-- { primitive = "Transition", stackError = Error.new(), value = nil } +-- ) --[[ ROBLOX CHECK: check if 'hookLog' is an Array ]] +-- return { function(callback) end, false } +-- end +-- local function useDeferredValue(value: T): T +-- -- useDeferredValue() composes multiple hooks internally. +-- -- Advance the current hook index the same number of times +-- -- so that subsequent hooks have the right memoized state. +-- nextHook() -- State +-- nextHook() -- Effect +-- table.insert( +-- hookLog, +-- { primitive = "DeferredValue", stackError = Error.new(), value = value } +-- ) --[[ ROBLOX CHECK: check if 'hookLog' is an Array ]] +-- return value +-- end +-- ROBLOX deviation END +local function useOpaqueIdentifier(): OpaqueIDType | void + local hook = nextHook() -- State + -- ROBLOX deviation START: simplify + -- if + -- Boolean.toJSBoolean( + -- if Boolean.toJSBoolean(currentFiber) + -- then currentFiber.mode == NoMode + -- else currentFiber + -- ) + -- then + if currentFiber and currentFiber.mode == NoMode then + -- ROBLOX deviation END + nextHook() -- Effect + end + local value = if hook == nil then nil else hook.memoizedState + -- ROBLOX deviation START: simplify + -- if + -- Boolean.toJSBoolean( + -- if Boolean.toJSBoolean(value) + -- then value["$$typeof"] == REACT_OPAQUE_ID_TYPE + -- else value + -- ) + -- then + if value and (value :: any)["$$typeof"] == REACT_OPAQUE_ID_TYPE then + -- ROBLOX deviation END + value = nil + end + table.insert(hookLog, { primitive = "OpaqueIdentifier", stackError = Error.new(), value = value }) --[[ ROBLOX CHECK: check if 'hookLog' is an Array ]] + return value +end +-- ROBLOX deviation START: predefined variable +-- local Dispatcher: DispatcherType = { +Dispatcher = { + -- ROBLOX deviation END + readContext = readContext, + useCallback = useCallback, + useContext = useContext, + useEffect = useEffect, + -- ROBLOX deviation START: needs cast + -- useImperativeHandle = useImperativeHandle, + useImperativeHandle = useImperativeHandle :: any, + -- ROBLOX deviation END + useDebugValue = useDebugValue, + useLayoutEffect = useLayoutEffect, + -- ROBLOX deviation START: needs cast + -- useMemo = useMemo, + useMemo = useMemo :: any, + -- ROBLOX deviation END + useReducer = useReducer, + useRef = useRef, + -- ROBLOX deviation START: add useBinding + useBinding = useBinding, + -- ROBLOX deviation END + -- ROBLOX deviation START: needs cast + -- useState = useState, + useState = useState :: any, + -- ROBLOX deviation END + -- ROBLOX deviation START: not implemented + -- useTransition = useTransition, + -- ROBLOX deviation END + useMutableSource = useMutableSource, + -- ROBLOX deviation START: not implemented + -- useDeferredValue = useDeferredValue, + -- ROBLOX deviation END + useOpaqueIdentifier = useOpaqueIdentifier, +} -- Inspect +export type HooksNode = { + id: number | nil,--[[ ROBLOX CHECK: verify if `null` wasn't used differently than `undefined` ]] + isStateEditable: boolean, + name: string, + value: unknown, + subHooks: Array, +} --[[ ROBLOX CHECK: inexact type upstream which is not supported by Luau. Verify if it doesn't break the analyze ]] +export type HooksTree = Array -- Don't assume +-- +-- We can't assume that stack frames are nth steps away from anything. +-- E.g. we can't assume that the root call shares all frames with the stack +-- of a hook call. A simple way to demonstrate this is wrapping `new Error()` +-- in a wrapper constructor like a polyfill. That'll add an extra frame. +-- Similar things can happen with the call to the dispatcher. The top frame +-- may not be the primitive. Likewise the primitive can have fewer stack frames +-- such as when a call to useState got inlined to use dispatcher.useState. +-- +-- We also can't assume that the last frame of the root call is the same +-- frame as the last frame of the hook call because long stack traces can be +-- truncated to a stack trace limit. +-- ROBLOX deviation START: adapt to 1-based indexing +-- local mostLikelyAncestorIndex = 0 +local mostLikelyAncestorIndex = 1 +-- ROBLOX deviation END +-- ROBLOX deviation START: explicit type +-- local function findSharedIndex(hookStack, rootStack, rootIndex) +local function findSharedIndex(hookStack, rootStack, rootIndex: number) + -- ROBLOX deviation END + -- ROBLOX deviation START: don't use tostring + -- local source = rootStack[tostring(rootIndex)].source + local source = rootStack[rootIndex].source + -- ROBLOX deviation END + -- ROBLOX deviation START: implement LabeledStatement + -- error("not implemented") --[[ ROBLOX TODO: Unhandled node for type: LabeledStatement ]] --[[ hookSearch: for (let i = 0; i < hookStack.length; i++) { + -- if (hookStack[i].source === source) { + -- // This looks like a match. Validate that the rest of both stack match up. + -- for (let a = rootIndex + 1, b = i + 1; a < rootStack.length && b < hookStack.length; a++, b++) { + -- if (hookStack[b].source !== rootStack[a].source) { + -- // If not, give up and try a different match. + -- continue hookSearch; + -- } + -- } + + -- return i; + -- } + -- } ]] + for i = 1, #hookStack do + if hookStack[i].source == source then + -- This looks like a match. Validate that the rest of both stack match up. + -- ROBLOX deviation: rewrite complex loop + local a = rootIndex + 1 + local b = i + 1 + local skipReturn = false + while a <= #rootStack and b <= #hookStack do + if hookStack[b].source ~= rootStack[a].source then + -- If not, give up and try a different match. + skipReturn = true + break + end + a += 1 + b += 1 + end + if not skipReturn then + return i + end + end + end + -- ROBLOX deviation END + return -1 +end +local function findCommonAncestorIndex(rootStack, hookStack) + local rootIndex = findSharedIndex(hookStack, rootStack, mostLikelyAncestorIndex) + if rootIndex ~= -1 then + return rootIndex + end -- If the most likely one wasn't a hit, try any other frame to see if it is shared. + -- If that takes more than 5 frames, something probably went wrong. + -- ROBLOX deviation START: use numeric for loop + -- do + -- local i = 0 + -- while + -- i < rootStack.length --[[ ROBLOX CHECK: operator '<' works only if either both arguments are strings or both are a number ]] + -- and i < 5 --[[ ROBLOX CHECK: operator '<' works only if either both arguments are strings or both are a number ]] + -- do + -- rootIndex = findSharedIndex(hookStack, rootStack, i) + -- if rootIndex ~= -1 then + -- mostLikelyAncestorIndex = i + -- return rootIndex + -- end + -- i += 1 + -- end + -- end + for i = 1, math.min(#rootStack, 5) do + rootIndex = findSharedIndex(hookStack, rootStack, i) + if rootIndex ~= -1 then + mostLikelyAncestorIndex = i + return rootIndex + end + end + -- ROBLOX deviation END + return -1 +end +local function isReactWrapper(functionName, primitiveName) + -- ROBLOX deviation START: simplify + -- if not Boolean.toJSBoolean(functionName) then + if not functionName or functionName == "" then + -- ROBLOX deviation END + return false + end + local expectedPrimitiveName = "use" .. tostring(primitiveName) + -- ROBLOX deviation START: fix length implementation + Luau doesn't understand the guard above + -- if + -- functionName.length + -- < expectedPrimitiveName.length --[[ ROBLOX CHECK: operator '<' works only if either both arguments are strings or both are a number ]] + -- then + if string.len(functionName :: string) < string.len(expectedPrimitiveName) then + -- ROBLOX deviation END + return false + end + -- ROBLOX deviation START: fix length implementation + Luau doesn't understand the guard above + -- return functionName:lastIndexOf(expectedPrimitiveName) + -- == functionName.length - expectedPrimitiveName.length + return String.lastIndexOf(functionName :: string, expectedPrimitiveName) + == (string.len(functionName :: string) - string.len(expectedPrimitiveName) + 1) + -- ROBLOX deviation END +end +local function findPrimitiveIndex(hookStack, hook) + local stackCache = getPrimitiveStackCache() + local primitiveStack = stackCache:get(hook.primitive) + if primitiveStack == nil then + return -1 + end + -- ROBLOX deviation START: use numeric for loop and precompute iteration count + -- do + -- local i = 0 + -- while + -- i < primitiveStack.length --[[ ROBLOX CHECK: operator '<' works only if either both arguments are strings or both are a number ]] + -- and i < hookStack.length --[[ ROBLOX CHECK: operator '<' works only if either both arguments are strings or both are a number ]] + -- do + -- if primitiveStack[tostring(i)].source ~= hookStack[tostring(i)].source then + -- -- If the next two frames are functions called `useX` then we assume that they're part of the + -- -- wrappers that the React packager or other packages adds around the dispatcher. + -- if + -- Boolean.toJSBoolean( + -- i < hookStack.length - 1 --[[ ROBLOX CHECK: operator '<' works only if either both arguments are strings or both are a number ]] + -- and isReactWrapper( + -- hookStack[tostring(i)].functionName, + -- hook.primitive + -- ) + -- ) + -- then + -- i += 1 + -- end + -- if + -- Boolean.toJSBoolean( + -- i < hookStack.length - 1 --[[ ROBLOX CHECK: operator '<' works only if either both arguments are strings or both are a number ]] + -- and isReactWrapper( + -- hookStack[tostring(i)].functionName, + -- hook.primitive + -- ) + -- ) + -- then + -- i += 1 + -- end + -- return i + -- end + -- i += 1 + -- end + -- end + for i = 1, math.min(#primitiveStack :: Array, #hookStack) do + if (primitiveStack :: Array)[i].source ~= hookStack[i].source then + -- If the next two frames are functions called `useX` then we assume that they're part of the + -- wrappers that the React packager or other packages adds around the dispatcher. + -- ROBLOX NOTE: 1-indexed so drop -1 + if i < #hookStack and isReactWrapper(hookStack[i].functionName, hook.primitive) then + i += 1 + end + -- ROBLOX NOTE: 1-indexed so drop -1 + if i < #hookStack and isReactWrapper(hookStack[i].functionName, hook.primitive) then + i += 1 + end + return i + end + end + -- ROBLOX deviation END + return -1 +end +-- ROBLOX deviation START: Luau doesn't infer Array | nil like it should +-- local function parseTrimmedStack(rootStack, hook) +local function parseTrimmedStack(rootStack, hook): Array? + -- ROBLOX deviation END + -- Get the stack trace between the primitive hook function and + -- the root function call. I.e. the stack frames of custom hooks. + -- ROBLOX deviation START: use dot notation + -- local hookStack = ErrorStackParser:parse(hook.stackError) + local hookStack = ErrorStackParser.parse(hook.stackError) + -- ROBLOX deviation END + local rootIndex = findCommonAncestorIndex(rootStack, hookStack) + local primitiveIndex = findPrimitiveIndex(hookStack, hook) + if + rootIndex == -1 + or primitiveIndex == -1 + or rootIndex - primitiveIndex < 2 --[[ ROBLOX CHECK: operator '<' works only if either both arguments are strings or both are a number ]] + then + -- Something went wrong. Give up. + return nil + end + return Array.slice(hookStack, primitiveIndex, rootIndex - 1) --[[ ROBLOX CHECK: check if 'hookStack' is an Array ]] +end +local function parseCustomHookName(functionName: void | string): string + -- ROBLOX deviation START: simplify + -- if not Boolean.toJSBoolean(functionName) then + if not functionName then + -- ROBLOX deviation END + return "" + end + -- ROBLOX deviation START: fix implementation + -- local startIndex = functionName:lastIndexOf(".") + local startIndex = String.lastIndexOf(functionName :: string, ".") + -- ROBLOX deviation END + if startIndex == -1 then + -- ROBLOX deviation START: adapt for 1-based indexing + -- startIndex = 0 + startIndex = 1 + -- ROBLOX deviation END + end + -- ROBLOX deviation START: fix implementation + -- if functionName:substr(startIndex, 3) == "use" then + if String.substr(functionName :: string, startIndex, 3) == "use" then + -- ROBLOX deviation END + startIndex += 3 + end + -- ROBLOX deviation START: fix implementation + -- return functionName:substr(startIndex) + return String.substr(functionName :: string, startIndex) + -- ROBLOX deviation END +end +-- ROBLOX deviation START: add predefined function +local processDebugValues +-- ROBLOX deviation END +-- ROBLOX deviation START: explicit type +-- local function buildTree(rootStack, readHookLog): HooksTree +local function buildTree(rootStack, readHookLog: Array): HooksTree + -- ROBLOX deviation END + local rootChildren = {} + local prevStack = nil + local levelChildren = rootChildren + -- ROBLOX deviation START: adjust for 1-based indexing + -- local nativeHookID = 0 + local nativeHookID = 1 + -- ROBLOX deviation END + local stackOfChildren = {} + -- ROBLOX deviation START: use numeric for loop + -- do + -- local i = 0 + -- while + -- i + -- < readHookLog.length --[[ ROBLOX CHECK: operator '<' works only if either both arguments are strings or both are a number ]] + -- do + -- local hook = readHookLog[tostring(i)] + -- local stack = parseTrimmedStack(rootStack, hook) + -- if stack ~= nil then + -- -- Note: The indices 0 <= n < length-1 will contain the names. + -- -- The indices 1 <= n < length will contain the source locations. + -- -- That's why we get the name from n - 1 and don't check the source + -- -- of index 0. + -- local commonSteps = 0 + -- if prevStack ~= nil then + -- -- Compare the current level's stack to the new stack. + -- while + -- commonSteps < stack.length --[[ ROBLOX CHECK: operator '<' works only if either both arguments are strings or both are a number ]] + -- and commonSteps < prevStack.length --[[ ROBLOX CHECK: operator '<' works only if either both arguments are strings or both are a number ]] + -- do + -- local stackSource = + -- stack[tostring(stack.length - commonSteps - 1)].source + -- local prevSource = + -- prevStack[tostring(prevStack.length - commonSteps - 1)].source + -- if stackSource ~= prevSource then + -- break + -- end + -- commonSteps += 1 + -- end -- Pop back the stack as many steps as were not common. + -- do + -- local j = prevStack.length - 1 + -- while + -- j + -- > commonSteps --[[ ROBLOX CHECK: operator '>' works only if either both arguments are strings or both are a number ]] + -- do + -- levelChildren = table.remove(stackOfChildren) --[[ ROBLOX CHECK: check if 'stackOfChildren' is an Array ]] + -- j -= 1 + -- end + -- end + -- end -- The remaining part of the new stack are custom hooks. Push them + -- -- to the tree. + -- do + -- local j = stack.length - commonSteps - 1 + -- while + -- j + -- >= 1 --[[ ROBLOX CHECK: operator '>=' works only if either both arguments are strings or both are a number ]] + -- do + -- local children = {} + -- table.insert(levelChildren, { + -- id = nil, + -- isStateEditable = false, + -- name = parseCustomHookName( + -- stack[tostring(j - 1)].functionName + -- ), + -- value = nil, + -- subHooks = children, + -- }) --[[ ROBLOX CHECK: check if 'levelChildren' is an Array ]] + -- table.insert(stackOfChildren, levelChildren) --[[ ROBLOX CHECK: check if 'stackOfChildren' is an Array ]] + -- levelChildren = children + -- j -= 1 + -- end + -- end + -- prevStack = stack + -- end + -- local primitive = hook.primitive -- For now, the "id" of stateful hooks is just the stateful hook index. + -- -- Custom hooks have no ids, nor do non-stateful native hooks (e.g. Context, DebugValue). + -- local id = if primitive == "Context" or primitive == "DebugValue" + -- then nil + -- else (function() + -- local ref = nativeHookID + -- nativeHookID += 1 + -- return ref + -- end)() -- For the time being, only State and Reducer hooks support runtime overrides. + -- local isStateEditable = primitive == "Reducer" or primitive == "State" + -- table.insert(levelChildren, { + -- id = id, + -- isStateEditable = isStateEditable, + -- name = primitive, + -- value = hook.value, + -- subHooks = {}, + -- }) --[[ ROBLOX CHECK: check if 'levelChildren' is an Array ]] + -- i += 1 + -- end + -- end -- Associate custom hook values (useDebugValue() hook entries) with the correct hooks. + for i = 1, #readHookLog do + local hook = readHookLog[i] + local stack = parseTrimmedStack(rootStack, hook) + + if stack ~= nil then + -- Note: The indices 0 <= n < length-1 will contain the names. + -- The indices 1 <= n < length will contain the source locations. + -- That's why we get the name from n - 1 and don't check the source + -- of index 0. + local commonSteps = 0 + if prevStack ~= nil then + -- Compare the current level's stack to the new stack. + while commonSteps < #stack and commonSteps < #prevStack do + local stackSource = stack[#stack - commonSteps].source + local prevSource = prevStack[#prevStack - commonSteps].source + + if stackSource ~= prevSource then + break + end + + commonSteps += 1 + end + -- Pop back the stack as many steps as were not common. + for j = #prevStack - 1, commonSteps + 1, -1 do + levelChildren = table.remove(stackOfChildren :: Array) :: Array + end + end + + -- The remaining part of the new stack are custom hooks. Push them + -- to the tree. + for j = #stack - commonSteps, 2, -1 do + local children = {} + table.insert(levelChildren, { + -- ROBLOX FIXME Luau: Luau should infer number | nil here by (at least) looking at the function-level usage + id = nil :: number | nil, + isStateEditable = false, + name = parseCustomHookName(stack[j - 1].functionName), + value = nil, + subHooks = children, + }) + table.insert(stackOfChildren, levelChildren) + levelChildren = children + end + + prevStack = stack + end + + local function POSTFIX_INCREMENT() + local prev = nativeHookID + nativeHookID += 1 + return prev + end + + local primitive = hook.primitive + + -- For now, the "id" of stateful hooks is just the stateful hook index. + -- Custom hooks have no ids, nor do non-stateful native hooks (e.g. Context, DebugValue). + -- ROBLOX FIXME Luau: Luau doesn't infer number | nil like it should + local id = if primitive == "Context" or primitive == "DebugValue" then nil else POSTFIX_INCREMENT() + -- For the time being, only State and Reducer hooks support runtime overrides. + local isStateEditable = primitive == "Reducer" or primitive == "State" + + table.insert(levelChildren, { + id = id, + isStateEditable = isStateEditable, + name = primitive, + value = hook.value, + subHooks = {}, + }) + end + -- ROBLOX deviation END + processDebugValues(rootChildren, nil) + return rootChildren +end -- Custom hooks support user-configurable labels (via the special useDebugValue() hook). +-- That hook adds user-provided values to the hooks tree, +-- but these values aren't intended to appear alongside of the other hooks. +-- Instead they should be attributed to their parent custom hook. +-- This method walks the tree and assigns debug values to their custom hook owners. +-- ROBLOX deviation START: predefined function +-- local function processDebugValues( +function processDebugValues( + -- ROBLOX deviation END + hooksTree: HooksTree, + parentHooksNode: HooksNode | nil --[[ ROBLOX CHECK: verify if `null` wasn't used differently than `undefined` ]] +): () + local debugValueHooksNodes: Array = {} + do + -- ROBLOX deviation START: adapt for 1-based indexing + -- local i = 0 + local i = 1 + -- ROBLOX deviation END + -- ROBLOX deviation START: fix length implementation + -- while + -- i + -- < hooksTree.length --[[ ROBLOX CHECK: operator '<' works only if either both arguments are strings or both are a number ]] + -- do + while i <= #hooksTree do + -- ROBLOX deviation END + -- ROBLOX deviation START: don't use tostring for iterating an array + -- local hooksNode = hooksTree[tostring(i)] + local hooksNode = hooksTree[i] + -- ROBLOX deviation END + -- ROBLOX deviation START: fix length implementation + -- if hooksNode.name == "DebugValue" and hooksNode.subHooks.length == 0 then + if hooksNode.name == "DebugValue" and #hooksNode.subHooks == 0 then + -- ROBLOX deviation END + Array.splice(hooksTree, i, 1) --[[ ROBLOX CHECK: check if 'hooksTree' is an Array ]] + i -= 1 + table.insert(debugValueHooksNodes, hooksNode) --[[ ROBLOX CHECK: check if 'debugValueHooksNodes' is an Array ]] + else + processDebugValues(hooksNode.subHooks, hooksNode) + end + i += 1 + end + end -- Bubble debug value labels to their custom hook owner. + -- If there is no parent hook, just ignore them for now. + -- (We may warn about this in the future.) + if parentHooksNode ~= nil then + -- ROBLOX deviation START: fix length implementation + -- if debugValueHooksNodes.length == 1 then + if #debugValueHooksNodes == 1 then + -- ROBLOX deviation END + parentHooksNode.value = debugValueHooksNodes[ + 1 --[[ ROBLOX adaptation: added 1 to array index ]] + ].value + -- ROBLOX deviation START: fix length implementation + -- elseif + -- debugValueHooksNodes.length + -- > 1 --[[ ROBLOX CHECK: operator '>' works only if either both arguments are strings or both are a number ]] + -- then + elseif #debugValueHooksNodes > 1 then + -- ROBLOX deviation END + parentHooksNode.value = Array.map(debugValueHooksNodes, function(ref0) + local value = ref0.value + return value + end) --[[ ROBLOX CHECK: check if 'debugValueHooksNodes' is an Array ]] + end + end +end +local function inspectHooks( + renderFunction: (Props) -> React_Node --[[ ROBLOX CHECK: replaced unhandled characters in identifier. Original identifier: React$Node ]], + props: Props, + currentDispatcher: CurrentDispatcherRef? +): HooksTree + -- DevTools will pass the current renderer's injected dispatcher. + -- Other apps might compile debug hooks as part of their app though. + if + currentDispatcher == nil --[[ ROBLOX CHECK: loose equality used upstream ]] + then + currentDispatcher = ReactSharedInternals.ReactCurrentDispatcher + end + -- ROBLOX deviation START: Luau doesn't understand that currentDispatcher is not nil + -- local previousDispatcher = currentDispatcher.current + local previousDispatcher = (currentDispatcher :: CurrentDispatcherRef).current + -- ROBLOX deviation END + local readHookLog; + -- ROBLOX deviation START: Luau doesn't understand that currentDispatcher is not nil + -- currentDispatcher.current = Dispatcher + (currentDispatcher :: CurrentDispatcherRef).current = Dispatcher + -- ROBLOX deviation END + local ancestorStackError + do --[[ ROBLOX COMMENT: try-finally block conversion ]] + -- ROBLOX deviation START: doesn't return + -- local ok, result, hasReturned = pcall(function() + local ok, result = pcall(function() + -- ROBLOX deviation END + ancestorStackError = Error.new() + renderFunction(props) + end) + do + readHookLog = hookLog + hookLog = {}; + -- ROBLOX deviation START: Luau doesn't understand that currentDispatcher is not nil + -- currentDispatcher.current = previousDispatcher + (currentDispatcher :: CurrentDispatcherRef).current = previousDispatcher + -- ROBLOX deviation END + end + -- ROBLOX deviation START: doesn't return + -- if hasReturned then + -- return result + -- end + -- ROBLOX deviation END + if not ok then + error(result) + end + end + -- ROBLOX deviation START: use dot notation + -- local rootStack = ErrorStackParser:parse(ancestorStackError) + local rootStack = ErrorStackParser.parse(ancestorStackError) + -- ROBLOX deviation END + return buildTree(rootStack, readHookLog) +end +exports.inspectHooks = inspectHooks +local function setupContexts(contextMap: Map, any>, fiber: Fiber) + local current = fiber + -- ROBLOX deviation START: toJSBoolean not needed + -- while Boolean.toJSBoolean(current) do + while current do + -- ROBLOX deviation END + if current.tag == ContextProvider then + local providerType: ReactProviderType = current.type + local context: ReactContext = providerType._context + -- ROBLOX deviation START: toJSBoolean not needed + -- if not Boolean.toJSBoolean(contextMap:has(context)) then + if not contextMap:has(context) then + -- ROBLOX deviation END + -- Store the current value that we're going to restore later. + contextMap:set(context, context._currentValue) -- Set the inner most provider value on the context. + context._currentValue = current.memoizedProps.value + end + end + -- ROBLOX deviation START: use return_ + -- current = current["return"] + current = current.return_ :: Fiber + -- ROBLOX deviation END + end +end +local function restoreContexts(contextMap: Map, any>) + -- ROBLOX deviation START: use for..in loop + -- Array.forEach(contextMap, function(value, context) + -- context._currentValue = value + -- return context._currentValue + -- end) --[[ ROBLOX CHECK: check if 'contextMap' is an Array ]] + for _, ref in contextMap do + local context, value = ref[1], ref[2] + context._currentValue = value + end + -- ROBLOX deviation END +end +local function inspectHooksOfForwardRef( + renderFunction: (Props, Ref) -> React_Node --[[ ROBLOX CHECK: replaced unhandled characters in identifier. Original identifier: React$Node ]], + props: Props, + ref: Ref, + currentDispatcher: CurrentDispatcherRef +): HooksTree + local previousDispatcher = currentDispatcher.current + local readHookLog + currentDispatcher.current = Dispatcher + local ancestorStackError + do --[[ ROBLOX COMMENT: try-finally block conversion ]] + -- ROBLOX deviation START: doesn't return + -- local ok, result, hasReturned = pcall(function() + local ok, result = pcall(function() + -- ROBLOX deviation END + ancestorStackError = Error.new() + renderFunction(props, ref) + end) + do + readHookLog = hookLog + hookLog = {} + currentDispatcher.current = previousDispatcher + end + -- ROBLOX deviation START: doesn't return + -- if hasReturned then + -- return result + -- end + -- ROBLOX deviation END + if not ok then + error(result) + end + end + -- ROBLOX deviation START: use dot notation + -- local rootStack = ErrorStackParser:parse(ancestorStackError) + local rootStack = ErrorStackParser.parse(ancestorStackError) + -- ROBLOX deviation END + return buildTree(rootStack, readHookLog) +end +-- ROBLOX deviation START: explicit type +-- local function resolveDefaultProps(Component, baseProps) +local function resolveDefaultProps(Component, baseProps: Object) + -- ROBLOX deviation END + -- ROBLOX deviation START: toJSBoolean not needed + -- if + -- Boolean.toJSBoolean( + -- if Boolean.toJSBoolean(Component) then Component.defaultProps else Component + -- ) + -- then + if typeof(Component) == "table" and Component.defaultProps then + -- ROBLOX deviation END + -- Resolve default props. Taken from ReactElement + local props = Object.assign({}, baseProps) + local defaultProps = Component.defaultProps + for propName in defaultProps do + -- ROBLOX deviation START: needs cast + -- if props[tostring(propName)] == nil then + -- props[tostring(propName)] = defaultProps[tostring(propName)] + if (props :: Object)[propName] == nil then + (props :: Object)[propName] = defaultProps[propName] + end + -- ROBLOX deviation END + end + return props + end + return baseProps +end +local function inspectHooksOfFiber(fiber: Fiber, currentDispatcher: CurrentDispatcherRef?) + -- DevTools will pass the current renderer's injected dispatcher. + -- Other apps might compile debug hooks as part of their app though. + if + currentDispatcher == nil --[[ ROBLOX CHECK: loose equality used upstream ]] + then + currentDispatcher = ReactSharedInternals.ReactCurrentDispatcher + end + currentFiber = fiber + if + fiber.tag ~= FunctionComponent + and fiber.tag ~= SimpleMemoComponent + and fiber.tag ~= ForwardRef + and fiber.tag ~= Block + then + error(Error.new("Unknown Fiber. Needs to be a function component to inspect hooks.")) + end -- Warm up the cache so that it doesn't consume the currentHook. + getPrimitiveStackCache() + local type_ = fiber.type + local props = fiber.memoizedProps + if type_ ~= fiber.elementType then + props = resolveDefaultProps(type_, props) + end -- Set up the current hook so that we can step through and read the + -- current state from them. + currentHook = fiber.memoizedState :: Hook + local contextMap = Map.new() + do --[[ ROBLOX COMMENT: try-finally block conversion ]] + -- ROBLOX deviation START: doesn't need conditional return + -- local ok, result, hasReturned = pcall(function() + local ok, result = pcall(function() + -- ROBLOX deviation END + setupContexts(contextMap, fiber) + if fiber.tag == ForwardRef then + return inspectHooksOfForwardRef( + type_.render, + props, + fiber.ref, + -- ROBLOX deviation START: needs cast + -- currentDispatcher + currentDispatcher :: CurrentDispatcherRef + -- ROBLOX deviation END + ) + end + return inspectHooks(type_, props, currentDispatcher) + end) + do + currentHook = nil + restoreContexts(contextMap) + end + -- ROBLOX deviation START: doesn't need conditional return + -- if hasReturned then + -- return result + -- end + -- ROBLOX deviation END + if not ok then + error(result) + end + -- ROBLOX deviation START: add return + return result + -- ROBLOX deviation END + end +end +exports.inspectHooksOfFiber = inspectHooksOfFiber +return exports diff --git a/packages/react-debug-tools/src/ReactDebugTools.lua b/packages/react-debug-tools/src/ReactDebugTools.lua new file mode 100644 index 00000000..a218566e --- /dev/null +++ b/packages/react-debug-tools/src/ReactDebugTools.lua @@ -0,0 +1,20 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.2/packages/react-debug-tools/src/ReactDebugTools.js +--[[* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + ]] +local exports = {} +local reactDebugHooksModule = require(script.Parent.ReactDebugHooks) +-- ROBLOX deviation START: add re-exporting of types +export type HooksNode = reactDebugHooksModule.HooksNode +export type HooksTree = reactDebugHooksModule.HooksTree +-- ROBLOX deviation END +local inspectHooks = reactDebugHooksModule.inspectHooks +local inspectHooksOfFiber = reactDebugHooksModule.inspectHooksOfFiber +exports.inspectHooks = inspectHooks +exports.inspectHooksOfFiber = inspectHooksOfFiber +return exports diff --git a/packages/react-debug-tools/src/__tests__/ReactDevToolsHooksIntegration.spec.lua b/packages/react-debug-tools/src/__tests__/ReactDevToolsHooksIntegration.spec.lua new file mode 100644 index 00000000..8ee4314e --- /dev/null +++ b/packages/react-debug-tools/src/__tests__/ReactDevToolsHooksIntegration.spec.lua @@ -0,0 +1,638 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.2/packages/react-debug-tools/src/__tests__/ReactDevToolsHooksIntegration-test.js +--[[* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + * @jest-environment node + ]] +local Packages = script.Parent.Parent.Parent +local LuauPolyfill = require(Packages.LuauPolyfill) +-- ROBLOX deviation START: not needed +-- local Boolean = LuauPolyfill.Boolean +-- ROBLOX deviation END +local Error = LuauPolyfill.Error +local JestGlobals = require(Packages.Dev.JestGlobals) +-- ROBLOX deviation START: add additional import +local afterEach = JestGlobals.afterEach +-- ROBLOX deviation END +local beforeEach = JestGlobals.beforeEach +local describe = JestGlobals.describe +local expect = JestGlobals.expect +local it = JestGlobals.it +local jest = JestGlobals.jest + +describe("React hooks DevTools integration", function() + local React + local ReactDebugTools + local ReactTestRenderer + local Scheduler + local act + local overrideHookState + local scheduleUpdate + local setSuspenseHandler + beforeEach(function() + -- ROBLOX deviation START: use _G instead of global + -- global.__REACT_DEVTOOLS_GLOBAL_HOOK__ = { + _G.__REACT_DEVTOOLS_GLOBAL_HOOK__ = { + -- ROBLOX deviation END + inject = function(injected) + overrideHookState = injected.overrideHookState + scheduleUpdate = injected.scheduleUpdate + setSuspenseHandler = injected.setSuspenseHandler + end, + supportsFiber = true, + onCommitFiberRoot = function() end, + onCommitFiberUnmount = function() end, + } + jest.resetModules() + -- ROBLOX deviation START: fix requires + -- React = require_("react") + -- ReactDebugTools = require_("react-debug-tools") + -- ReactTestRenderer = require_("react-test-renderer") + -- Scheduler = require_("scheduler") + ReactTestRenderer = require(Packages.Dev.ReactTestRenderer) + React = require(Packages.Dev.React) + ReactDebugTools = require(Packages.ReactDebugTools) + Scheduler = require(Packages.Dev.Scheduler) + -- ROBLOX deviation END + act = ReactTestRenderer.act + end) + -- ROBLOX deviation START: add afterEach to revert global flag + afterEach(function() + _G.__REACT_DEVTOOLS_GLOBAL_HOOK__ = nil + end) + -- ROBLOX deviation END + it("should support editing useState hooks", function() + local setCountFn + local function MyComponent() + -- ROBLOX deviation START: useState returns 2 values + -- local count, setCount = table.unpack(React.useState(0), 1, 2) + local count, setCount = React.useState(0) + -- ROBLOX deviation END + setCountFn = setCount + -- ROBLOX deviation START: use TextLabel instead + -- return React.createElement("div", nil, "count:", count) + return React.createElement( + "Frame", + nil, + React.createElement("TextLabel", { Text = "count:" }), + React.createElement("TextLabel", { Text = tostring(count) }) + ) + -- ROBLOX deviation END + end + local renderer = ReactTestRenderer.create(React.createElement(MyComponent, nil)) + expect(renderer:toJSON()).toEqual({ + -- ROBLOX deviation START: use Frame instead + -- type = "div", + type = "Frame", + -- ROBLOX deviation END + props = {}, + -- ROBLOX deviation START: use TextLabels instead + -- children = { "count:", "0" }, + children = { + { type = "TextLabel", props = { Text = "count:" } }, + { type = "TextLabel", props = { Text = "0" } }, + }, + -- ROBLOX deviation END + }) + local fiber = renderer.root:findByType(MyComponent):_currentFiber() + -- ROBLOX deviation START: use dot notation + -- local tree = ReactDebugTools:inspectHooksOfFiber(fiber) + local tree = ReactDebugTools.inspectHooksOfFiber(fiber) + -- ROBLOX deviation END + local stateHook = tree[ + 1 --[[ ROBLOX adaptation: added 1 to array index ]] + ] + expect(stateHook.isStateEditable).toBe(true) + -- ROBLOX deviation START: use _G.__DEV__ + -- if Boolean.toJSBoolean(__DEV__) then + if _G.__DEV__ then + -- ROBLOX deviation END + overrideHookState(fiber, stateHook.id, {}, 10) + expect(renderer:toJSON()).toEqual({ + -- ROBLOX deviation START: use Frame instead + -- type = "div", + type = "Frame", + -- ROBLOX deviation END + props = {}, + -- ROBLOX deviation START: use TextLabels instead + -- children = { "count:", "10" }, + children = { + { type = "TextLabel", props = { Text = "count:" } }, + { type = "TextLabel", props = { Text = "10" } }, + }, + -- ROBLOX deviation END + }) + act(function() + return setCountFn(function(count) + return count + 1 + end) + end) + expect(renderer:toJSON()).toEqual({ + -- ROBLOX deviation START: use Frame instead + -- type = "div", + type = "Frame", + -- ROBLOX deviation END + props = {}, + -- ROBLOX deviation START: use TextLabels instead + -- children = { "count:", "11" }, + children = { + { type = "TextLabel", props = { Text = "count:" } }, + { type = "TextLabel", props = { Text = "11" } }, + }, + -- ROBLOX deviation END + }) + end + end) + it("should support editable useReducer hooks", function() + local initialData = { foo = "abc", bar = 123 } + local function reducer(state, action) + local condition_ = action.type + if condition_ == "swap" then + return { foo = state.bar, bar = state.foo } + else + error(Error.new()) + end + end + local dispatchFn + local function MyComponent() + -- ROBLOX deviation START: returns 2 values + -- local state, dispatch = + -- table.unpack(React.useReducer(reducer, initialData), 1, 2) + -- ROBLOX deviation END + local state, dispatch = React.useReducer(reducer, initialData) + dispatchFn = dispatch + -- ROBLOX deviation START: use Frame and TextLabels instead + -- return React.createElement("div", nil, "foo:", state.foo, ", bar:", state.bar) + return React.createElement( + "Frame", + {}, + React.createElement("TextLabel", { Text = "foo:" }), + React.createElement("TextLabel", { Text = tostring(state.foo) }), + React.createElement("TextLabel", { Text = ", bar:" }), + React.createElement("TextLabel", { Text = tostring(state.bar) }) + ) + -- ROBLOX deviation END + end + local renderer = ReactTestRenderer.create(React.createElement(MyComponent, nil)) + expect(renderer:toJSON()).toEqual({ + -- ROBLOX deviation START: use Frame instead + -- type = "div", + type = "Frame", + -- ROBLOX deviation END + props = {}, + -- ROBLOX deviation START: use TextLabels instead + -- children = { "foo:", "abc", ", bar:", "123" }, + children = { + { type = "TextLabel", props = { Text = "foo:" } }, + { type = "TextLabel", props = { Text = "abc" } }, + { type = "TextLabel", props = { Text = ", bar:" } }, + { type = "TextLabel", props = { Text = "123" } }, + }, + -- ROBLOX deviation END + }) + local fiber = renderer.root:findByType(MyComponent):_currentFiber() + -- ROBLOX deviation START: use dot notation + -- local tree = ReactDebugTools:inspectHooksOfFiber(fiber) + local tree = ReactDebugTools.inspectHooksOfFiber(fiber) + -- ROBLOX deviation END + local reducerHook = tree[ + 1 --[[ ROBLOX adaptation: added 1 to array index ]] + ] + expect(reducerHook.isStateEditable).toBe(true) + -- ROBLOX deviation START: use _G.__DEV__ + -- if Boolean.toJSBoolean(__DEV__) then + if _G.__DEV__ then + -- ROBLOX deviation END + overrideHookState(fiber, reducerHook.id, { "foo" }, "def") + expect(renderer:toJSON()).toEqual({ + -- ROBLOX deviation START: use Frame instead + -- type = "div", + type = "Frame", + -- ROBLOX deviation END + props = {}, + -- ROBLOX deviation START: use TextLabels instead + -- children = { "foo:", "def", ", bar:", "123" }, + children = { + { type = "TextLabel", props = { Text = "foo:" } }, + { type = "TextLabel", props = { Text = "def" } }, + { type = "TextLabel", props = { Text = ", bar:" } }, + { type = "TextLabel", props = { Text = "123" } }, + }, + -- ROBLOX deviation END + }) + act(function() + return dispatchFn({ type = "swap" }) + end) + expect(renderer:toJSON()).toEqual({ + -- ROBLOX deviation START: use Frame instead + -- type = "div", + type = "Frame", + -- ROBLOX deviation END + props = {}, + -- ROBLOX deviation START: use TextLabels instead + -- children = { "foo:", "123", ", bar:", "def" }, + children = { + { type = "TextLabel", props = { Text = "foo:" } }, + { type = "TextLabel", props = { Text = "123" } }, + { type = "TextLabel", props = { Text = ", bar:" } }, + { type = "TextLabel", props = { Text = "def" } }, + }, + -- ROBLOX deviation END + }) + end + end) -- This test case is based on an open source bug report: + -- facebookincubator/redux-react-hook/issues/34#issuecomment-466693787 + it("should handle interleaved stateful hooks (e.g. useState) and non-stateful hooks (e.g. useContext)", function() + local MyContext = React.createContext(1) + local setStateFn + local function useCustomHook() + local context = React.useContext(MyContext) + -- ROBLOX deviation START: returns 2 values + -- local state, setState = + -- table.unpack(React.useState({ count = context }), 1, 2) + local state, setState = React.useState({ count = context }) + -- ROBLOX deviation END + React.useDebugValue(state.count) + setStateFn = setState + return state.count + end + local function MyComponent() + local count = useCustomHook() + -- ROBLOX deviation START: use Frame and TextLabels instead + -- return React.createElement("div", nil, "count:", count) + return React.createElement( + "Frame", + nil, + React.createElement("TextLabel", { Text = "count:" }), + React.createElement("TextLabel", { Text = tostring(count) }) + ) + -- ROBLOX deviation END + end + local renderer = ReactTestRenderer.create(React.createElement(MyComponent, nil)) + expect(renderer:toJSON()).toEqual({ + -- ROBLOX deviation START: use Frame instead + -- type = "div", + type = "Frame", + -- ROBLOX deviation END + props = {}, + -- ROBLOX deviation START: use TextLabels instead + -- children = { "count:", "1" }, + children = { + { type = "TextLabel", props = { Text = "count:" } }, + { type = "TextLabel", props = { Text = "1" } }, + }, + -- ROBLOX deviation END + }) + local fiber = renderer.root:findByType(MyComponent):_currentFiber() + -- ROBLOX deviation START: use dot notation + -- local tree = ReactDebugTools:inspectHooksOfFiber(fiber) + local tree = ReactDebugTools.inspectHooksOfFiber(fiber) + -- ROBLOX deviation END + local stateHook = tree[ + 1 --[[ ROBLOX adaptation: added 1 to array index ]] + ].subHooks[ + 2 --[[ ROBLOX adaptation: added 1 to array index ]] + ] + expect(stateHook.isStateEditable).toBe(true) + -- ROBLOX deviation START: use _G.__DEV__ + -- if Boolean.toJSBoolean(__DEV__) then + if _G.__DEV__ then + -- ROBLOX deviation END + overrideHookState(fiber, stateHook.id, { "count" }, 10) + expect(renderer:toJSON()).toEqual({ + -- ROBLOX deviation START: use Frame instead + -- type = "div", + type = "Frame", + -- ROBLOX deviation END + props = {}, + -- ROBLOX deviation START: use TextLabels instead + -- children = { "count:", "10" }, + children = { + { type = "TextLabel", props = { Text = "count:" } }, + { type = "TextLabel", props = { Text = "10" } }, + }, + -- ROBLOX deviation END + }) + act(function() + return setStateFn(function(state) + return { count = state.count + 1 } + end) + end) + expect(renderer:toJSON()).toEqual({ + -- ROBLOX deviation START: use Frame instead + -- type = "div", + type = "Frame", + -- ROBLOX deviation END + props = {}, + -- ROBLOX deviation START: use TextLabels instead + -- children = { "count:", "11" }, + children = { + { type = "TextLabel", props = { Text = "count:" } }, + { type = "TextLabel", props = { Text = "11" } }, + }, + -- ROBLOX deviation END + }) + end + end) + it("should support overriding suspense in legacy mode", function() + -- ROBLOX deviation START: use _G.__DEV__ + -- if Boolean.toJSBoolean(__DEV__) then + if _G.__DEV__ then + -- ROBLOX deviation END + -- Lock the first render + setSuspenseHandler(function() + return true + end) + end + local function MyComponent() + -- ROBLOX deviation START: use TextLabel instead + -- return "Done" + return React.createElement("TextLabel", { Text = "Done" }) + -- ROBLOX deviation END + end + local renderer = ReactTestRenderer.create(React.createElement( + -- ROBLOX deviation START: use Frame instead + -- "div", + "Frame", + -- ROBLOX deviation END + nil, + React.createElement( + React.Suspense, + -- ROBLOX deviation START: use TextLabel instead + -- { fallback = "Loading" }, + { fallback = React.createElement("TextLabel", { Text = "Loading" }) }, + -- ROBLOX deviation END + React.createElement(MyComponent, nil) + ) + )) + local fiber = renderer.root:_currentFiber().child + -- ROBLOX deviation START: use _G.__DEV__ + -- if Boolean.toJSBoolean(__DEV__) then + if _G.__DEV__ then + -- ROBLOX deviation END + -- First render was locked + -- ROBLOX deviation START: use TextLabel instead + -- expect(renderer:toJSON().children).toEqual({ "Loading" }) + expect(renderer:toJSON().children).toEqual({ + { + type = "TextLabel", + props = { Text = "Loading" }, + }, + }) + -- ROBLOX deviation END + scheduleUpdate(fiber) -- Re-render + -- ROBLOX deviation START: use TextLabel instead + -- expect(renderer:toJSON().children).toEqual({ "Loading" }) -- Release the lock + expect(renderer:toJSON().children).toEqual({ + { + type = "TextLabel", + props = { Text = "Loading" }, + }, + }) + -- ROBLOX deviation END + setSuspenseHandler(function() + return false + end) + scheduleUpdate(fiber) -- Re-render + -- ROBLOX deviation START: use TextLabel instead + -- expect(renderer:toJSON().children).toEqual({ "Done" }) + expect(renderer:toJSON().children).toEqual({ + { + type = "TextLabel", + props = { Text = "Done" }, + }, + }) + -- ROBLOX deviation END + scheduleUpdate(fiber) -- Re-render + -- ROBLOX deviation START: use TextLabel instead + -- expect(renderer:toJSON().children).toEqual({ "Done" }) -- Lock again + expect(renderer:toJSON().children).toEqual({ + { + type = "TextLabel", + props = { Text = "Done" }, + }, + }) + -- ROBLOX deviation END + setSuspenseHandler(function() + return true + end) + scheduleUpdate(fiber) -- Re-render + -- ROBLOX deviation START: use TextLabel instead + -- expect(renderer:toJSON().children).toEqual({ "Loading" }) -- Release the lock again + expect(renderer:toJSON().children).toEqual({ + { + type = "TextLabel", + props = { Text = "Loading" }, + }, + }) + -- ROBLOX deviation END + setSuspenseHandler(function() + return false + end) + scheduleUpdate(fiber) -- Re-render + -- ROBLOX deviation START: use TextLabel instead + -- expect(renderer:toJSON().children).toEqual({ "Done" }) -- Ensure it checks specific fibers. + expect(renderer:toJSON().children).toEqual({ + { + type = "TextLabel", + props = { Text = "Done" }, + }, + }) + -- ROBLOX deviation END + setSuspenseHandler(function(f) + return f == fiber or f == fiber.alternate + end) + scheduleUpdate(fiber) -- Re-render + -- ROBLOX deviation START: use TextLabel instead + -- expect(renderer:toJSON().children).toEqual({ "Loading" }) + expect(renderer:toJSON().children).toEqual({ + { + type = "TextLabel", + props = { Text = "Loading" }, + }, + }) + -- ROBLOX deviation END + setSuspenseHandler(function(f) + return f ~= fiber and f ~= fiber.alternate + end) + scheduleUpdate(fiber) -- Re-render + -- ROBLOX deviation START: use TextLabel instead + -- expect(renderer:toJSON().children).toEqual({ "Done" }) + expect(renderer:toJSON().children).toEqual({ + { + type = "TextLabel", + props = { Text = "Done" }, + }, + }) + -- ROBLOX deviation END + else + -- ROBLOX deviation START: use TextLabel instead + -- expect(renderer:toJSON().children).toEqual({ "Done" }) + expect(renderer:toJSON().children).toEqual({ + { + type = "TextLabel", + props = { Text = "Done" }, + }, + }) + -- ROBLOX deviation END + end + end) + it("should support overriding suspense in concurrent mode", function() + -- ROBLOX deviation START: add useFakeTimers + jest.useFakeTimers() + -- ROBLOX deviation END + -- ROBLOX deviation START: use _G.__DEV__ + -- if Boolean.toJSBoolean(__DEV__) then + if _G.__DEV__ then + -- ROBLOX deviation END + -- Lock the first render + setSuspenseHandler(function() + return true + end) + end + local function MyComponent() + -- ROBLOX deviation START: use TextLabel instead + -- return "Done" + return React.createElement("TextLabel", { Text = "Done" }) + -- ROBLOX deviation END + end + local renderer = ReactTestRenderer.create( + React.createElement( + "div", + nil, + React.createElement( + React.Suspense, + -- ROBLOX deviation START: use TextLabel instead + -- { fallback = "Loading" }, + { fallback = React.createElement("TextLabel", { Text = "Loading" }) }, + -- ROBLOX deviation END + React.createElement(MyComponent, nil) + ) + ), + { unstable_isConcurrent = true } + ) + expect(Scheduler).toFlushAndYield({}) -- Ensure we timeout any suspense time. + jest.advanceTimersByTime(1000) + local fiber = renderer.root:_currentFiber().child + -- ROBLOX deviation START: use _G.__DEV__ + -- if Boolean.toJSBoolean(__DEV__) then + if _G.__DEV__ then + -- ROBLOX deviation END + -- First render was locked + -- ROBLOX deviation START: use TextLabel instead + -- expect(renderer:toJSON().children).toEqual({ "Loading" }) + expect(renderer:toJSON().children).toEqual({ + { + type = "TextLabel", + props = { Text = "Loading" }, + }, + }) + -- ROBLOX deviation END + scheduleUpdate(fiber) -- Re-render + -- ROBLOX deviation START: use TextLabel instead + -- expect(renderer:toJSON().children).toEqual({ "Loading" }) -- Release the lock + expect(renderer:toJSON().children).toEqual({ + { + type = "TextLabel", + props = { Text = "Loading" }, + }, + }) + -- ROBLOX deviation END + setSuspenseHandler(function() + return false + end) + scheduleUpdate(fiber) -- Re-render + -- ROBLOX deviation START: use dot notation + -- Scheduler:unstable_flushAll() + Scheduler.unstable_flushAll() + -- ROBLOX deviation END + -- ROBLOX deviation START: use TextLabel instead + -- expect(renderer:toJSON().children).toEqual({ "Done" }) + expect(renderer:toJSON().children).toEqual({ + { + type = "TextLabel", + props = { Text = "Done" }, + }, + }) + -- ROBLOX deviation END + scheduleUpdate(fiber) -- Re-render + -- ROBLOX deviation START: use TextLabel instead + -- expect(renderer:toJSON().children).toEqual({ "Done" }) -- Lock again + expect(renderer:toJSON().children).toEqual({ + { + type = "TextLabel", + props = { Text = "Done" }, + }, + }) + -- ROBLOX deviation END + setSuspenseHandler(function() + return true + end) + scheduleUpdate(fiber) -- Re-render + -- ROBLOX deviation START: use TextLabel instead + -- expect(renderer:toJSON().children).toEqual({ "Loading" }) -- Release the lock again + expect(renderer:toJSON().children).toEqual({ + { + type = "TextLabel", + props = { Text = "Loading" }, + }, + }) + -- ROBLOX deviation END + setSuspenseHandler(function() + return false + end) + scheduleUpdate(fiber) -- Re-render + -- ROBLOX deviation START: use TextLabel instead + -- expect(renderer:toJSON().children).toEqual({ "Done" }) -- Ensure it checks specific fibers. + expect(renderer:toJSON().children).toEqual({ + { + type = "TextLabel", + props = { Text = "Done" }, + }, + }) + -- ROBLOX deviation END + setSuspenseHandler(function(f) + return f == fiber or f == fiber.alternate + end) + scheduleUpdate(fiber) -- Re-render + -- ROBLOX deviation START: use TextLabel instead + -- expect(renderer:toJSON().children).toEqual({ "Loading" }) + expect(renderer:toJSON().children).toEqual({ + { + type = "TextLabel", + props = { Text = "Loading" }, + }, + }) + -- ROBLOX deviation END + setSuspenseHandler(function(f) + return f ~= fiber and f ~= fiber.alternate + end) + scheduleUpdate(fiber) -- Re-render + -- ROBLOX deviation START: use TextLabel instead + -- expect(renderer:toJSON().children).toEqual({ "Done" }) + expect(renderer:toJSON().children).toEqual({ + { + type = "TextLabel", + props = { Text = "Done" }, + }, + }) + -- ROBLOX deviation END + else + -- ROBLOX deviation START: use TextLabel instead + -- expect(renderer:toJSON().children).toEqual({ "Done" }) + expect(renderer:toJSON().children).toEqual({ + { + type = "TextLabel", + props = { Text = "Done" }, + }, + }) + end + -- ROBLOX deviation START: add useRealTimers + jest.useRealTimers() + -- ROBLOX deviation END + end) +end) diff --git a/packages/react-debug-tools/src/__tests__/ReactHooksInspection.spec.lua b/packages/react-debug-tools/src/__tests__/ReactHooksInspection.spec.lua new file mode 100644 index 00000000..3e0ef6ed --- /dev/null +++ b/packages/react-debug-tools/src/__tests__/ReactHooksInspection.spec.lua @@ -0,0 +1,496 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.2/packages/react-debug-tools/src/__tests__/ReactHooksInspection-test.js +--[[* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + * @jest-environment node + ]] +local Packages = script.Parent.Parent.Parent +-- ROBLOX deviation START: not needed +-- local LuauPolyfill = require(Packages.LuauPolyfill) +-- local Boolean = LuauPolyfill.Boolean +-- ROBLOX deviation END +local JestGlobals = require(Packages.Dev.JestGlobals) +local beforeEach = JestGlobals.beforeEach +local describe = JestGlobals.describe +local expect = JestGlobals.expect +local it = JestGlobals.it +local jest = JestGlobals.jest + +local React +local ReactDebugTools +describe("ReactHooksInspection", function() + beforeEach(function() + jest.resetModules() + -- ROBLOX deviation START: fix requires + -- React = require_("react") + -- ReactDebugTools = require_("react-debug-tools") + React = require(Packages.Dev.React) + ReactDebugTools = require(Packages.ReactDebugTools) + -- ROBLOX deviation END + end) + it("should inspect a simple useState hook", function() + local function Foo(props) + -- ROBLOX deviation START: useState returns 2 values + -- local state = React.useState("hello world")[1] + local state = React.useState("hello world") + -- ROBLOX deviation END + return React.createElement("div", nil, state) + end + -- ROBLOX deviation START: use dot notation + -- local tree = ReactDebugTools:inspectHooks(Foo, {}) + local tree = ReactDebugTools.inspectHooks(Foo, {}) + -- ROBLOX deviation END + expect(tree).toEqual({ + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 0, + id = 1, + -- ROBLOX deviation END + name = "State", + value = "hello world", + subHooks = {}, + }, + }) + end) + it("should inspect a simple custom hook", function() + local function useCustom(value) + local state = React.useState(value)[1] + React.useDebugValue("custom hook label") + return state + end + local function Foo(props) + local value = useCustom("hello world") + return React.createElement("div", nil, value) + end + -- ROBLOX deviation START: use dot notation + -- local tree = ReactDebugTools:inspectHooks(Foo, {}) + local tree = ReactDebugTools.inspectHooks(Foo, {}) + -- ROBLOX deviation END + expect(tree).toEqual({ + { + isStateEditable = false, + id = nil, + name = "Custom", + -- ROBLOX deviation START: use _G.__DEV__ + -- value = if Boolean.toJSBoolean(__DEV__) then "custom hook label" else nil, + value = if _G.__DEV__ then "custom hook label" else nil, + -- ROBLOX deviation END + subHooks = { + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 0, + id = 1, + -- ROBLOX deviation END + name = "State", + value = "hello world", + subHooks = {}, + }, + }, + }, + }) + end) + it("should inspect a tree of multiple hooks", function() + local function effect() end + local function useCustom(value) + -- ROBLOX deviation START: useState returns 2 values + -- local state = React.useState(value)[1] + local state = React.useState(value) + -- ROBLOX deviation END + React.useEffect(effect) + return state + end + local function Foo(props) + local value1 = useCustom("hello") + local value2 = useCustom("world") + return React.createElement("div", nil, value1, " ", value2) + end + -- ROBLOX deviation START: use dot notation + -- local tree = ReactDebugTools:inspectHooks(Foo, {}) + local tree = ReactDebugTools.inspectHooks(Foo, {}) + -- ROBLOX deviation END + expect(tree).toEqual({ + { + isStateEditable = false, + id = nil, + name = "Custom", + value = nil, + subHooks = { + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 0, + id = 1, + -- ROBLOX deviation END + name = "State", + subHooks = {}, + -- ROBLOX deviation START: tell Luau to type this field loosely + value = "hello" :: any, + -- ROBLOX deviation END + }, + { + isStateEditable = false, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 1, + id = 2, + -- ROBLOX deviation END + name = "Effect", + subHooks = {}, + value = effect, + }, + }, + }, + { + isStateEditable = false, + id = nil, + name = "Custom", + value = nil, + subHooks = { + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 2, + id = 3, + -- ROBLOX deviation END + name = "State", + -- ROBLOX deviation START: Luau doesn't support mixed arrays + -- value = "world", + value = "world" :: any, + -- ROBLOX deviation END + subHooks = {}, + }, + { + isStateEditable = false, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 3, + id = 4, + -- ROBLOX deviation END + name = "Effect", + value = effect, + subHooks = {}, + }, + }, + }, + }) + end) + it("should inspect a tree of multiple levels of hooks", function() + local function effect() end + local function useCustom(value) + local state = React.useReducer(function(s, a) + return s + -- ROBLOX deviation START: useReducer returns 2 values + -- end, value)[1] + end, value) + -- ROBLOX deviation END + React.useEffect(effect) + return state + end + local function useBar(value) + local result = useCustom(value) + React.useLayoutEffect(effect) + return result + end + local function useBaz(value) + React.useLayoutEffect(effect) + local result = useCustom(value) + return result + end + local function Foo(props) + local value1 = useBar("hello") + local value2 = useBaz("world") + return React.createElement("div", nil, value1, " ", value2) + end + -- ROBLOX deviation START: use dot notation + -- local tree = ReactDebugTools:inspectHooks(Foo, {}) + local tree = ReactDebugTools.inspectHooks(Foo, {}) + -- ROBLOX deviation END + expect(tree).toEqual({ + { + isStateEditable = false, + -- ROBLOX deviation START: tell Luau to type this field loosely + id = nil :: number?, + -- ROBLOX deviation END + name = "Bar", + value = nil, + subHooks = { + { + isStateEditable = false, + -- ROBLOX deviation START: Luau doesn't support mixed arrays + -- id = nil, + id = nil :: number | nil, + -- ROBLOX deviation END + name = "Custom", + -- ROBLOX deviation START: Luau doesn't support mixed arrays + -- value = nil, + value = nil :: any, + -- ROBLOX deviation END + subHooks = { + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 0, + id = 1, + -- ROBLOX deviation END + name = "Reducer", + -- ROBLOX deviation START: Luau doesn't support mixed arrays + -- value = "hello", + value = "hello" :: any, + -- ROBLOX deviation END + subHooks = {}, + }, + { + isStateEditable = false, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 1, + id = 2, + -- ROBLOX deviation END + name = "Effect", + value = effect, + subHooks = {}, + }, + }, + }, + { + isStateEditable = false, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 2, + id = 3, + -- ROBLOX deviation END + name = "LayoutEffect", + value = effect, + subHooks = {}, + }, + }, + }, + { + isStateEditable = false, + id = nil, + name = "Baz", + value = nil, + subHooks = { + { + isStateEditable = false, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 3, + id = 4 :: number?, + -- ROBLOX deviation END + name = "LayoutEffect", + -- ROBLOX deviation START: Luau doesn't support mixed arrays + -- value = effect, + value = effect :: any, + -- ROBLOX deviation END + subHooks = {}, + }, + { + isStateEditable = false, + id = nil, + name = "Custom", + subHooks = { + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 4, + id = 5, + -- ROBLOX deviation END + name = "Reducer", + subHooks = {}, + -- ROBLOX deviation START: Luau doesn't support mixed arrays + -- value = "world", + value = "world" :: any, + -- ROBLOX deviation END + }, + { + isStateEditable = false, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 5, + id = 6, + -- ROBLOX deviation END + name = "Effect", + subHooks = {}, + value = effect, + }, + }, + value = nil, + }, + }, + }, + }) + end) + it("should inspect the default value using the useContext hook", function() + local MyContext = React.createContext("default") + local function Foo(props) + local value = React.useContext(MyContext) + return React.createElement("div", nil, value) + end + -- ROBLOX deviation START: use dot notation + -- local tree = ReactDebugTools:inspectHooks(Foo, {}) + local tree = ReactDebugTools.inspectHooks(Foo, {}) + -- ROBLOX deviation END + expect(tree).toEqual({ + { + isStateEditable = false, + id = nil, + name = "Context", + value = "default", + subHooks = {}, + }, + }) + end) + it("should support an injected dispatcher", function() + local function Foo(props) + -- ROBLOX deviation START: useState returns 2 values + -- local state = React.useState("hello world")[1] + local state = React.useState("hello world") + -- ROBLOX deviation END + return React.createElement("div", nil, state) + end + local initial = {} + local current = initial + local getterCalls = 0 + local setterCalls = {} + -- ROBLOX deviation START: implement getter and setter + -- local FakeDispatcherRef = { + -- current = function(self) + -- getterCalls += 1 + -- return current + -- end, + -- current = function(self, value) + -- table.insert(setterCalls, value) --[[ ROBLOX CHECK: check if 'setterCalls' is an Array ]] + -- current = value + -- end, + -- } + local FakeDispatcherRef = setmetatable({ + __getters = { + current = function(self) + print("getting current") + getterCalls += 1 + return current + end, + }, + __setters = { + current = function(self, value) + print("setting current", value) + table.insert(setterCalls, value) + current = value + end, + }, + }, { + __index = function(self, key) + if typeof(self.__getters[key]) == "function" then + return self.__getters[key](self) + else + return nil + end + end, + __newindex = function(self, key, value) + if typeof(self.__setters[key]) == "function" then + return self.__setters[key](self, value) + else + return nil + end + end, + }) :: any + -- ROBLOX deviation END + -- ROBLOX deviation START: aligned to React 18 so we get a hot path optimization in upstream + -- expect(function() + -- ReactDebugTools:inspectHooks(Foo, {}, FakeDispatcherRef) + -- end).toThrow( + -- "Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for" + -- .. " one of the following reasons:\n" + -- .. "1. You might have mismatching versions of React and the renderer (such as React DOM)\n" + -- .. "2. You might be breaking the Rules of Hooks\n" + -- .. "3. You might have more than one copy of React in the same app\n" + -- .. "See https://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem." + -- ) + local didCatch = false + expect(function() + -- mock the Error constructor to check the internal of the error instance + expect(function() + ReactDebugTools.inspectHooks(Foo, {}, FakeDispatcherRef) + end).toThrow( + -- ROBLOX NOTE: Lua-specific error on nil deref + "attempt to index nil with 'useState'" + ) + didCatch = true + end).toErrorDev( + "Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for" + .. " one of the following reasons:\n" + .. "1. You might have mismatching versions of React and the renderer (such as React DOM)\n" + .. "2. You might be breaking the Rules of Hooks\n" + .. "3. You might have more than one copy of React in the same app\n" + .. "See https://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem.", + { withoutStack = true } + ) + -- avoid false positive if no error was thrown at all + expect(didCatch).toBe(true) + -- ROBLOX deviation END + expect(getterCalls).toBe(1) + expect(setterCalls).toHaveLength(2) + expect(setterCalls[ + 1 --[[ ROBLOX adaptation: added 1 to array index ]] + -- ROBLOX deviation START: use never instead of ["not"] + -- ])["not"].toBe(initial) + ]).never.toBe(initial) + -- ROBLOX deviation END + expect(setterCalls[ + 2 --[[ ROBLOX adaptation: added 1 to array index ]] + ]).toBe(initial) + end) + describe("useDebugValue", function() + it("should be ignored when called outside of a custom hook", function() + local function Foo(props) + React.useDebugValue("this is invalid") + return nil + end + -- ROBLOX deviation START: use dot notation + -- local tree = ReactDebugTools:inspectHooks(Foo, {}) + local tree = ReactDebugTools.inspectHooks(Foo, {}) + -- ROBLOX deviation END + expect(tree).toHaveLength(0) + end) + it("should support an optional formatter function param", function() + local function useCustom() + React.useDebugValue({ bar = 123 }, function(object) + return ("bar:%s"):format(tostring(object.bar)) + end) + React.useState(0) + end + local function Foo(props) + useCustom() + return nil + end + -- ROBLOX deviation START: use dot notation + -- local tree = ReactDebugTools:inspectHooks(Foo, {}) + local tree = ReactDebugTools.inspectHooks(Foo, {}) + -- ROBLOX deviation END + expect(tree).toEqual({ + { + isStateEditable = false, + id = nil, + name = "Custom", + -- ROBLOX deviation START: use _G.__DEV__ + -- value = if Boolean.toJSBoolean(__DEV__) then "bar:123" else nil, + value = if _G.__DEV__ then "bar:123" else nil, + -- ROBLOX deviation END + subHooks = { + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 0, + id = 1, + -- ROBLOX deviation END + name = "State", + subHooks = {}, + value = 0, + }, + }, + }, + }) + end) + end) +end) diff --git a/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration.spec.lua b/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration.spec.lua new file mode 100644 index 00000000..fe9a0294 --- /dev/null +++ b/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration.spec.lua @@ -0,0 +1,1293 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.2/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js +--[[* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + * @jest-environment node + ]] +local Packages = script.Parent.Parent.Parent +local LuauPolyfill = require(Packages.LuauPolyfill) +-- ROBLOX deviation START: not needed +-- local Boolean = LuauPolyfill.Boolean +-- ROBLOX deviation END +-- ROBLOX deviation START: import from dev dependencies +-- local Promise = require(Packages.Promise) +local Promise = require(Packages.Dev.Promise) +-- ROBLOX deviation END +local JestGlobals = require(Packages.Dev.JestGlobals) +local beforeEach = JestGlobals.beforeEach +local describe = JestGlobals.describe +local expect = JestGlobals.expect +local it = JestGlobals.it +local jest = JestGlobals.jest +-- ROBLOX deviation START: add additional imports +local String = LuauPolyfill.String +-- ROBLOX deviation END + +local React +local ReactTestRenderer +local Scheduler +local ReactDebugTools +local act +describe("ReactHooksInspectionIntegration", function() + beforeEach(function() + jest.resetModules() + -- ROBLOX deviation START: fix requires + -- React = require_("react") + -- ReactTestRenderer = require_("react-test-renderer") + -- Scheduler = require_("scheduler") + ReactTestRenderer = require(Packages.Dev.ReactTestRenderer) + Scheduler = require(Packages.Dev.Scheduler) + React = require(Packages.Dev.React) + -- ROBLOX deviation END + act = ReactTestRenderer.unstable_concurrentAct + -- ROBLOX deviation START: fix requires + -- ReactDebugTools = require_("react-debug-tools") + ReactDebugTools = require(Packages.ReactDebugTools) + -- ROBLOX deviation END + end) + it("should inspect the current state of useState hooks", function() + local useState = React.useState + local function Foo(props) + -- ROBLOX deviation START: useState returns 2 values + -- local state1, setState1 = table.unpack(useState("hello"), 1, 2) + -- local state2, setState2 = table.unpack(useState("world"), 1, 2) + local state1, setState1 = useState("hello") + local state2, setState2 = useState("world") + -- ROBLOX deviation END + return React.createElement( + -- ROBLOX deviation START: use Frame instead + -- "div", + "Frame", + -- ROBLOX deviation END + { onMouseDown = setState1, onMouseUp = setState2 }, + state1, + " ", + state2 + ) + end + local renderer = ReactTestRenderer.create(React.createElement(Foo, { prop = "prop" })) + local childFiber = renderer.root:findByType(Foo):_currentFiber() + -- ROBLOX deviation START: use dot notation + -- local tree = ReactDebugTools:inspectHooksOfFiber(childFiber) + local tree = ReactDebugTools.inspectHooksOfFiber(childFiber) + -- ROBLOX deviation END + expect(tree).toEqual({ + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 0, + id = 1, + -- ROBLOX deviation END + name = "State", + value = "hello", + subHooks = {}, + }, + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 1, + id = 2, + -- ROBLOX deviation END + name = "State", + value = "world", + subHooks = {}, + }, + }) + local setStateA, setStateB + do + -- ROBLOX deviation START: use Frame instead + -- local ref = renderer.root:findByType("div").props + local ref = renderer.root:findByType("Frame").props + -- ROBLOX deviation END + setStateA, setStateB = ref.onMouseDown, ref.onMouseUp + end + act(function() + return setStateA("Hi") + end) + childFiber = renderer.root:findByType(Foo):_currentFiber() + -- ROBLOX deviation START: use dot notation + -- tree = ReactDebugTools:inspectHooksOfFiber(childFiber) + tree = ReactDebugTools.inspectHooksOfFiber(childFiber) + -- ROBLOX deviation END + expect(tree).toEqual({ + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 0, + id = 1, + -- ROBLOX deviation END + name = "State", + value = "Hi", + subHooks = {}, + }, + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 1, + id = 2, + -- ROBLOX deviation END + name = "State", + value = "world", + subHooks = {}, + }, + }) + act(function() + return setStateB("world!") + end) + childFiber = renderer.root:findByType(Foo):_currentFiber() + -- ROBLOX deviation START: use dot notation + -- tree = ReactDebugTools:inspectHooksOfFiber(childFiber) + tree = ReactDebugTools.inspectHooksOfFiber(childFiber) + -- ROBLOX deviation END + expect(tree).toEqual({ + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 0, + id = 1, + -- ROBLOX deviation END + name = "State", + value = "Hi", + subHooks = {}, + }, + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 1, + id = 2, + -- ROBLOX deviation END + name = "State", + value = "world!", + subHooks = {}, + }, + }) + end) + it("should inspect the current state of all stateful hooks", function() + local outsideRef = React.createRef() + local function effect() end + local function Foo(props) + -- ROBLOX deviation START: useState and useReducer return 2 values + -- local state1, setState = table.unpack(React.useState("a"), 1, 2) + -- local state2, dispatch = table.unpack( + -- React.useReducer(function(s, a) + -- return a.value + -- end, "b"), + -- 1, + -- 2 + -- ) + local state1, setState = React.useState("a") + local state2, dispatch = React.useReducer(function(s, a) + return a.value + end, "b") + -- ROBLOX deviation END + local ref = React.useRef("c") + React.useLayoutEffect(effect) + React.useEffect(effect) + React.useImperativeHandle(outsideRef, function() + -- Return a function so that jest treats them as non-equal. + return function() end + end, {}) + React.useMemo(function() + -- ROBLOX deviation START: use string concatenation + -- return state1 + state2 + return state1 .. state2 + -- ROBLOX deviation END + end, { state1 }) + local function update() + act(function() + setState("A") + end) + act(function() + dispatch({ value = "B" }) + end) + ref.current = "C" + end + local memoizedUpdate = React.useCallback(update, {}) + return React.createElement( + -- ROBLOX deviation START: use Frame instead + -- "div", + "Frame", + -- ROBLOX deviation END + { onClick = memoizedUpdate }, + state1, + " ", + state2 + ) + end + local renderer + act(function() + renderer = ReactTestRenderer.create(React.createElement(Foo, { prop = "prop" })) + end) + local childFiber = renderer.root:findByType(Foo):_currentFiber() + -- ROBLOX deviation START: use Frame instead + -- local updateStates = renderer.root:findByType("div").props.onClick + local updateStates = renderer.root:findByType("Frame").props.onClick + -- ROBLOX deviation END + -- ROBLOX deviation START: use dot notation + -- local tree = ReactDebugTools:inspectHooksOfFiber(childFiber) + local tree = ReactDebugTools.inspectHooksOfFiber(childFiber) + -- ROBLOX deviation END + expect(tree).toEqual({ + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 0, + id = 1, + -- ROBLOX deviation END + name = "State", + -- ROBLOX deviation START: tell Luau to type this field loosely + value = "a" :: any, + -- ROBLOX deviation END + subHooks = {}, + }, + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 1, + id = 2, + -- ROBLOX deviation END + name = "Reducer", + value = "b", + subHooks = {}, + }, + -- ROBLOX deviation START: adjust for 1-based indexing + -- { isStateEditable = false, id = 2, name = "Ref", value = "c", subHooks = {} }, + { isStateEditable = false, id = 3, name = "Ref", value = "c", subHooks = {} }, + -- ROBLOX deviation END + { + isStateEditable = false, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 3, + id = 4, + -- ROBLOX deviation END + name = "LayoutEffect", + value = effect, + subHooks = {}, + }, + { + isStateEditable = false, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 4, + id = 5, + -- ROBLOX deviation END + name = "Effect", + value = effect, + subHooks = {}, + }, + { + isStateEditable = false, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 5, + id = 6, + -- ROBLOX deviation END + name = "ImperativeHandle", + value = outsideRef.current, + subHooks = {}, + }, + { + isStateEditable = false, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 6, + id = 7, + -- ROBLOX deviation END + name = "Memo", + -- ROBLOX deviation START: useMemo wraps a value + -- value = "ab", + value = { "ab" }, + -- ROBLOX deviation END + subHooks = {}, + }, + { + isStateEditable = false, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 7, + id = 8, + -- ROBLOX deviation END + name = "Callback", + value = updateStates, + subHooks = {}, + }, + }) + updateStates() + childFiber = renderer.root:findByType(Foo):_currentFiber() + -- ROBLOX deviation START: use dot notation + -- tree = ReactDebugTools:inspectHooksOfFiber(childFiber) + tree = ReactDebugTools.inspectHooksOfFiber(childFiber) + -- ROBLOX deviation END + expect(tree).toEqual({ + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 0, + id = 1, + -- ROBLOX deviation END + name = "State", + -- ROBLOX deviation START: tell Luau to type this field loosely + value = "A" :: any, + -- ROBLOX deviation END + subHooks = {}, + }, + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 1, + id = 2, + -- ROBLOX deviation END + name = "Reducer", + value = "B", + subHooks = {}, + }, + -- ROBLOX deviation START: adjust for 1-based indexing + -- { isStateEditable = false, id = 2, name = "Ref", value = "C", subHooks = {} }, + { isStateEditable = false, id = 3, name = "Ref", value = "C", subHooks = {} }, + -- ROBLOX deviation END + { + isStateEditable = false, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 3, + id = 4, + -- ROBLOX deviation END + name = "LayoutEffect", + value = effect, + subHooks = {}, + }, + { + isStateEditable = false, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 4, + id = 5, + -- ROBLOX deviation END + name = "Effect", + value = effect, + subHooks = {}, + }, + { + isStateEditable = false, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 5, + id = 6, + -- ROBLOX deviation END + name = "ImperativeHandle", + value = outsideRef.current, + subHooks = {}, + }, + { + isStateEditable = false, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 6, + id = 7, + -- ROBLOX deviation END + name = "Memo", + -- ROBLOX deviation START: useMemo wraps a value + -- value = "Ab", + value = { "Ab" }, + -- ROBLOX deviation END + subHooks = {}, + }, + { + isStateEditable = false, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 7, + id = 8, + -- ROBLOX deviation END + name = "Callback", + value = updateStates, + subHooks = {}, + }, + }) + end) + it("should inspect the value of the current provider in useContext", function() + local MyContext = React.createContext("default") + local function Foo(props) + local value = React.useContext(MyContext) + -- ROBLOX deviation START: use Frame instead + -- return React.createElement("div", nil, value) + return React.createElement("Frame", nil, value) + -- ROBLOX deviation END + end + local renderer = ReactTestRenderer.create( + React.createElement( + MyContext.Provider, + { value = "contextual" }, + React.createElement(Foo, { prop = "prop" }) + ) + ) + local childFiber = renderer.root:findByType(Foo):_currentFiber() + -- ROBLOX deviation START: adjust for 1-based indexing + -- local tree = ReactDebugTools:inspectHooksOfFiber(childFiber) + local tree = ReactDebugTools.inspectHooksOfFiber(childFiber) + -- ROBLOX deviation END + expect(tree).toEqual({ + { + isStateEditable = false, + id = nil, + name = "Context", + value = "contextual", + subHooks = {}, + }, + }) + end) + it("should inspect forwardRef", function() + local function obj() end + local Foo = React.forwardRef(function(props, ref) + React.useImperativeHandle(ref, function() + return obj + end) + -- ROBLOX deviation START: use Frame instead + -- return React.createElement("div", nil) + return React.createElement("Frame", nil) + -- ROBLOX deviation END + end) + local ref = React.createRef() + local renderer = ReactTestRenderer.create(React.createElement(Foo, { ref = ref })) + local childFiber = renderer.root:findByType(Foo):_currentFiber() + -- ROBLOX deviation START: use dot notation + -- local tree = ReactDebugTools:inspectHooksOfFiber(childFiber) + local tree = ReactDebugTools.inspectHooksOfFiber(childFiber) + -- ROBLOX deviation END + expect(tree).toEqual({ + { + isStateEditable = false, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 0, + id = 1, + -- ROBLOX deviation END + name = "ImperativeHandle", + value = obj, + subHooks = {}, + }, + }) + end) + it("should inspect memo", function() + local function InnerFoo(props) + -- ROBLOX deviation START: useState returns 2 values + -- local value = React.useState("hello")[1] + local value = React.useState("hello") + -- ROBLOX deviation END + -- ROBLOX deviation START: use Frame instead + -- return React.createElement("div", nil, value) + return React.createElement("Frame", nil, value) + -- ROBLOX deviation END + end + local Foo = React.memo(InnerFoo) + local renderer = ReactTestRenderer.create(React.createElement(Foo, nil)) -- TODO: Test renderer findByType is broken for memo. Have to search for the inner. + local childFiber = renderer.root:findByType(InnerFoo):_currentFiber() + -- ROBLOX deviation START: use dot notation + -- local tree = ReactDebugTools:inspectHooksOfFiber(childFiber) + local tree = ReactDebugTools.inspectHooksOfFiber(childFiber) + -- ROBLOX deviation END + expect(tree).toEqual({ + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 0, + id = 1, + -- ROBLOX deviation END + name = "State", + value = "hello", + subHooks = {}, + }, + }) + end) + it("should inspect custom hooks", function() + local function useCustom() + -- ROBLOX deviation START: useState returns 2 values + -- local value = React.useState("hello")[1] + local value = React.useState("hello") + -- ROBLOX deviation END + return value + end + local function Foo(props) + local value = useCustom() + -- ROBLOX deviation START: use Frame instead + -- return React.createElement("div", nil, value) + return React.createElement("Frame", nil, value) + -- ROBLOX deviation END + end + local renderer = ReactTestRenderer.create(React.createElement(Foo, nil)) + local childFiber = renderer.root:findByType(Foo):_currentFiber() + -- ROBLOX deviation START: use dot notation + -- local tree = ReactDebugTools:inspectHooksOfFiber(childFiber) + local tree = ReactDebugTools.inspectHooksOfFiber(childFiber) + -- ROBLOX deviation END + expect(tree).toEqual({ + { + isStateEditable = false, + id = nil, + name = "Custom", + value = nil, + subHooks = { + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 0, + id = 1, + -- ROBLOX deviation END + name = "State", + value = "hello", + subHooks = {}, + }, + }, + }, + }) + end) -- @gate experimental + -- ROBLOX deviation START: unstable_useTransition is not implemented + -- it("should support composite useTransition hook", function() + it.skip("should support composite useTransition hook", function() + -- ROBLOX deviation END + local function Foo(props) + -- ROBLOX deviation START: not supported + -- React.unstable_useTransition() + -- ROBLOX deviation END + local memoizedValue = React.useMemo(function() + return "hello" + end, {}) + -- ROBLOX deviation START: use Frame instead + -- return React.createElement("div", nil, memoizedValue) + return React.createElement("Frame", nil, memoizedValue) + -- ROBLOX deviation END + end + local renderer = ReactTestRenderer.create(React.createElement(Foo, nil)) + local childFiber = renderer.root:findByType(Foo):_currentFiber() + -- ROBLOX deviation START: use dot notation + -- local tree = ReactDebugTools:inspectHooksOfFiber(childFiber) + local tree = ReactDebugTools.inspectHooksOfFiber(childFiber) + -- ROBLOX deviation END + expect(tree).toEqual({ + { + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 0, + id = 1, + -- ROBLOX deviation END + isStateEditable = false, + name = "Transition", + -- ROBLOX deviation START: tell Luau to type this field loosely + value = nil :: any, + -- ROBLOX deviation END + subHooks = {}, + }, + { + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 1, + id = 2, + -- ROBLOX deviation END + isStateEditable = false, + name = "Memo", + value = "hello", + subHooks = {}, + }, + }) + end) -- @gate experimental + -- ROBLOX deviation START: unstable_useDeferredValue not implemented + -- it("should support composite useDeferredValue hook", function() + it.skip("should support composite useDeferredValue hook", function() + -- ROBLOX deviation END + local function Foo(props) + -- ROBLOX deviation START: not implemented + -- React.unstable_useDeferredValue("abc", { timeoutMs = 500 }) + -- ROBLOX deviation END + local state = React.useState(function() + return "hello" + -- ROBLOX deviation START: useState returns 2 values + -- end, {})[1] + end, {}) + -- ROBLOX deviation END + -- ROBLOX deviation START: use Frame instead + -- return React.createElement("div", nil, state) + return React.createElement("Frame", nil, state) + -- ROBLOX deviation END + end + local renderer = ReactTestRenderer.create(React.createElement(Foo, nil)) + local childFiber = renderer.root:findByType(Foo):_currentFiber() + -- ROBLOX deviation START: use dot notation + -- local tree = ReactDebugTools:inspectHooksOfFiber(childFiber) + local tree = ReactDebugTools.inspectHooksOfFiber(childFiber) + -- ROBLOX deviation END + expect(tree).toEqual({ + { + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 0, + id = 1, + -- ROBLOX deviation END + isStateEditable = false, + name = "DeferredValue", + value = "abc", + subHooks = {}, + }, + { + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 1, + id = 2, + -- ROBLOX deviation END + isStateEditable = true, + name = "State", + value = "hello", + subHooks = {}, + }, + }) + end) -- @gate experimental + -- ROBLOX deviation START: unstable_useOpaqueIdentifier not implemented + -- it("should support composite useOpaqueIdentifier hook", function() + it.skip("should support composite useOpaqueIdentifier hook", function() + -- ROBLOX deviation END + local function Foo(props) + -- ROBLOX deviation START: not implemented + -- local id = React.unstable_useOpaqueIdentifier() + local id = nil + -- ROBLOX deviation END + local state = React.useState(function() + return "hello" + -- ROBLOX deviation START: useState returns 2 values + -- end, {})[1] + end, {}) + -- ROBLOX deviation END + -- ROBLOX deviation START: use Frame instead + -- return React.createElement("div", { id = id }, state) + return React.createElement("Frame", { id = id }, state) + -- ROBLOX deviation END + end + local renderer = ReactTestRenderer.create(React.createElement(Foo, nil)) + local childFiber = renderer.root:findByType(Foo):_currentFiber() + -- ROBLOX deviation START: use dot notation + -- local tree = ReactDebugTools:inspectHooksOfFiber(childFiber) + local tree = ReactDebugTools.inspectHooksOfFiber(childFiber) + -- ROBLOX deviation END + -- ROBLOX deviation START: fix length implementation + -- expect(tree.length).toEqual(2) + expect(#tree).toEqual(2) + -- ROBLOX deviation END + expect(tree[ + 1 --[[ ROBLOX adaptation: added 1 to array index ]] + ].id).toEqual(0) + expect(tree[ + 1 --[[ ROBLOX adaptation: added 1 to array index ]] + ].isStateEditable).toEqual(false) + expect(tree[ + 1 --[[ ROBLOX adaptation: added 1 to array index ]] + ].name).toEqual("OpaqueIdentifier") + -- ROBLOX deviation START: use String.startsWith + -- expect((tostring(tree[ + -- 1 --[[ ROBLOX adaptation: added 1 to array index ]] + -- ].value) .. ""):startsWith("c_")).toBe(true) + expect(String.startsWith(tree[1].value :: string .. "", "c_")).toBe(true) + -- ROBLOX deviation END + expect(tree[ + 2 --[[ ROBLOX adaptation: added 1 to array index ]] + ]).toEqual({ + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 1, + id = 2, + -- ROBLOX deviation END + isStateEditable = true, + name = "State", + value = "hello", + subHooks = {}, + }) + end) -- @gate experimental + -- ROBLOX deviation START: unstable_useOpaqueIdentifier not implemented + -- it("should support composite useOpaqueIdentifier hook in concurrent mode", function() + it.skip("should support composite useOpaqueIdentifier hook in concurrent mode", function() + -- ROBLOX deviation END + local function Foo(props) + -- ROBLOX FIXME: type this correctly when this is supported + local id = (React :: any).unstable_useOpaqueIdentifier() + local state = React.useState(function() + return "hello" + -- ROBLOX deviation START: useState returns 2 values + -- end, {})[1] + end, {}) + -- ROBLOX deviation END + -- ROBLOX deviation START: use Frame instead + -- return React.createElement("div", { id = id }, state) + return React.createElement("Frame", { id = id }, state) + -- ROBLOX deviation END + end + local renderer = ReactTestRenderer.create(React.createElement(Foo, nil), { unstable_isConcurrent = true }) + expect(Scheduler).toFlushWithoutYielding() + local childFiber = renderer.root:findByType(Foo):_currentFiber() + -- ROBLOX deviation START: use dot notation + -- local tree = ReactDebugTools:inspectHooksOfFiber(childFiber) + local tree = ReactDebugTools.inspectHooksOfFiber(childFiber) + -- ROBLOX deviation END + -- ROBLOX deviation START: fix length conversion + -- expect(tree.length).toEqual(2) + expect(#tree).toEqual(2) + -- ROBLOX deviation END + expect(tree[ + 1 --[[ ROBLOX adaptation: added 1 to array index ]] + ].id).toEqual(0) + expect(tree[ + 1 --[[ ROBLOX adaptation: added 1 to array index ]] + ].isStateEditable).toEqual(false) + expect(tree[ + 1 --[[ ROBLOX adaptation: added 1 to array index ]] + ].name).toEqual("OpaqueIdentifier") + -- ROBLOX deviation START: use String.startsWith + -- expect((tostring(tree[ + -- 1 --[[ ROBLOX adaptation: added 1 to array index ]] + -- ].value) .. ""):startsWith("c_")).toBe(true) + expect(String.startsWith(tree[1].value :: string .. "", "c_")).toBe(true) + -- ROBLOX deviation END + expect(tree[ + 2 --[[ ROBLOX adaptation: added 1 to array index ]] + ]).toEqual({ + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 1, + id = 2, + -- ROBLOX deviation END + isStateEditable = true, + name = "State", + value = "hello", + subHooks = {}, + }) + end) + describe("useDebugValue", function() + it("should support inspectable values for multiple custom hooks", function() + local function useLabeledValue(label) + -- ROBLOX deviation START: useState returns 2 values + -- local value = React.useState(label)[1] + local value = React.useState(label) + -- ROBLOX deviation END + React.useDebugValue(("custom label %s"):format(tostring(label))) + return value + end + local function useAnonymous(label) + -- ROBLOX deviation START: useState returns 2 values + -- local value = React.useState(label)[1] + local value = React.useState(label) + -- ROBLOX deviation END + return value + end + local function Example() + useLabeledValue("a") + React.useState("b") + useAnonymous("c") + useLabeledValue("d") + return nil + end + local renderer = ReactTestRenderer.create(React.createElement(Example, nil)) + local childFiber = renderer.root:findByType(Example):_currentFiber() + -- ROBLOX deviation START: use dot notation + -- local tree = ReactDebugTools:inspectHooksOfFiber(childFiber) + local tree = ReactDebugTools.inspectHooksOfFiber(childFiber) + -- ROBLOX deviation END + expect(tree).toEqual({ + { + isStateEditable = false, + -- ROBLOX deviation START: tell Luau to type this field loosely + id = nil :: number?, + -- ROBLOX deviation END + name = "LabeledValue", + -- ROBLOX deviation START: use _G.__DEV__ and cast + -- value = if Boolean.toJSBoolean(__DEV__) + -- then "custom label a" + -- else nil, + value = (if _G.__DEV__ then "custom label a" else nil) :: any, + -- ROBLOX deviation END + subHooks = { + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 0, + id = 1, + -- ROBLOX deviation END + name = "State", + value = "a", + subHooks = {}, + }, + }, + }, + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 1, + id = 2, + -- ROBLOX deviation END + name = "State", + value = "b", + subHooks = {}, + }, + { + isStateEditable = false, + id = nil, + name = "Anonymous", + value = nil, + subHooks = { + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 2, + id = 3, + -- ROBLOX deviation END + name = "State", + value = "c", + subHooks = {}, + }, + }, + }, + { + isStateEditable = false, + id = nil, + name = "LabeledValue", + -- ROBLOX deviation START: use _G.__DEV__ + -- value = if Boolean.toJSBoolean(__DEV__) + value = if _G.__DEV__ + -- ROBLOX deviation END + then "custom label d" + else nil, + subHooks = { + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 3, + id = 4, + -- ROBLOX deviation END + name = "State", + value = "d", + subHooks = {}, + }, + }, + }, + }) + end) + it("should support inspectable values for nested custom hooks", function() + local function useInner() + React.useDebugValue("inner") + React.useState(0) + end + local function useOuter() + React.useDebugValue("outer") + useInner() + end + local function Example() + useOuter() + return nil + end + local renderer = ReactTestRenderer.create(React.createElement(Example, nil)) + local childFiber = renderer.root:findByType(Example):_currentFiber() + -- ROBLOX deviation START: use dot notation + -- local tree = ReactDebugTools:inspectHooksOfFiber(childFiber) + local tree = ReactDebugTools.inspectHooksOfFiber(childFiber) + -- ROBLOX deviation END + expect(tree).toEqual({ + { + isStateEditable = false, + id = nil, + name = "Outer", + -- ROBLOX deviation START: use _G.__DEV__ + -- value = if Boolean.toJSBoolean(__DEV__) then "outer" else nil, + value = if _G.__DEV__ then "outer" else nil, + -- ROBLOX deviation END + subHooks = { + { + isStateEditable = false, + id = nil, + name = "Inner", + -- ROBLOX deviation START: use _G.__DEV__ + -- value = if Boolean.toJSBoolean(__DEV__) then "inner" else nil, + value = if _G.__DEV__ then "inner" else nil, + -- ROBLOX deviation END + subHooks = { + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 0, + id = 1, + -- ROBLOX deviation END + name = "State", + value = 0, + subHooks = {}, + }, + }, + }, + }, + }, + }) + end) + it("should support multiple inspectable values per custom hooks", function() + local function useMultiLabelCustom() + React.useDebugValue("one") + React.useDebugValue("two") + React.useDebugValue("three") + React.useState(0) + end + local function useSingleLabelCustom(value) + React.useDebugValue(("single %s"):format(tostring(value))) + React.useState(0) + end + local function Example() + useSingleLabelCustom("one") + useMultiLabelCustom() + useSingleLabelCustom("two") + return nil + end + local renderer = ReactTestRenderer.create(React.createElement(Example, nil)) + local childFiber = renderer.root:findByType(Example):_currentFiber() + -- ROBLOX deviation START: adjust for 1-based indexing + -- local tree = ReactDebugTools:inspectHooksOfFiber(childFiber) + local tree = ReactDebugTools.inspectHooksOfFiber(childFiber) + -- ROBLOX deviation END + expect(tree).toEqual({ + { + isStateEditable = false, + -- ROBLOX deviation START: Luau doesn't support mixed arrays + -- id = nil, + id = nil :: number | nil, + -- ROBLOX deviation END + name = "SingleLabelCustom", + -- ROBLOX deviation START: use _G.__DEV__ + -- value = if Boolean.toJSBoolean(__DEV__) then "single one" else nil, + value = (if _G.__DEV__ then "single one" else nil) :: any, + -- ROBLOX deviation END + subHooks = { + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 0, + id = 1, + -- ROBLOX deviation END + name = "State", + value = 0, + subHooks = {}, + }, + }, + }, + { + isStateEditable = false, + id = nil, + name = "MultiLabelCustom", + -- ROBLOX deviation START: use _G.__DEV__ + -- value = if Boolean.toJSBoolean(__DEV__) + value = if _G.__DEV__ + -- ROBLOX deviation END + then { "one", "two", "three" } + else nil, + subHooks = { + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 1, + id = 2, + -- ROBLOX deviation END + name = "State", + value = 0, + subHooks = {}, + }, + }, + }, + { + isStateEditable = false, + id = nil, + name = "SingleLabelCustom", + -- ROBLOX deviation START: use _G.__DEV__ + -- value = if Boolean.toJSBoolean(__DEV__) then "single two" else nil, + value = if _G.__DEV__ then "single two" else nil, + -- ROBLOX deviation END + subHooks = { + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 2, + id = 3, + -- ROBLOX deviation END + name = "State", + value = 0, + subHooks = {}, + }, + }, + }, + }) + end) + it("should ignore useDebugValue() made outside of a custom hook", function() + local function Example() + React.useDebugValue("this is invalid") + return nil + end + local renderer = ReactTestRenderer.create(React.createElement(Example, nil)) + local childFiber = renderer.root:findByType(Example):_currentFiber() + -- ROBLOX deviation START: use dot notation + -- local tree = ReactDebugTools:inspectHooksOfFiber(childFiber) + local tree = ReactDebugTools.inspectHooksOfFiber(childFiber) + -- ROBLOX deviation END + expect(tree).toHaveLength(0) + end) + it("should support an optional formatter function param", function() + local function useCustom() + React.useDebugValue({ bar = 123 }, function(object) + return ("bar:%s"):format(tostring(object.bar)) + end) + React.useState(0) + end + local function Example() + useCustom() + return nil + end + local renderer = ReactTestRenderer.create(React.createElement(Example, nil)) + local childFiber = renderer.root:findByType(Example):_currentFiber() + -- ROBLOX deviation START: use dot notation + -- local tree = ReactDebugTools:inspectHooksOfFiber(childFiber) + local tree = ReactDebugTools.inspectHooksOfFiber(childFiber) + -- ROBLOX deviation END + expect(tree).toEqual({ + { + isStateEditable = false, + id = nil, + name = "Custom", + -- ROBLOX deviation START: use _G.__DEV__ + -- value = if Boolean.toJSBoolean(__DEV__) then "bar:123" else nil, + value = if _G.__DEV__ then "bar:123" else nil, + -- ROBLOX deviation END + subHooks = { + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 0, + id = 1, + -- ROBLOX deviation END + name = "State", + subHooks = {}, + value = 0, + }, + }, + }, + }) + end) + end) + -- ROBLOX deviation START: defaultProps not supported for function components yet + -- it("should support defaultProps and lazy", function() + it.skip("should support defaultProps and lazy", function() + -- ROBLOX deviation END + return Promise.resolve():andThen(function() + -- ROBLOX deviation START: defaultProps not supported for function components yet + -- local Suspense = React.Suspense + -- local function Foo(props) + -- local value = React.useState(props.defaultValue:substr(0, 3))[1] + -- return React.createElement("div", nil, value) + -- end + -- Foo.defaultProps = { defaultValue = "default" } + -- local function fakeImport(result) + -- return Promise.resolve():andThen(function() + -- return { default = result } + -- end) + -- end + -- local LazyFoo = React.lazy(function() + -- return fakeImport(Foo) + -- end) + -- local renderer = ReactTestRenderer.create( + -- React.createElement( + -- Suspense, + -- { fallback = "Loading..." }, + -- React.createElement(LazyFoo, nil) + -- ) + -- ) + -- LazyFoo:expect() + -- Scheduler:unstable_flushAll() + -- local childFiber = renderer.root:_currentFiber() + -- local tree = ReactDebugTools:inspectHooksOfFiber(childFiber) + -- expect(tree).toEqual({ + -- { + -- isStateEditable = true, + -- id = 0, + -- name = "State", + -- value = "def", + -- subHooks = {}, + -- }, + -- }) + -- ROBLOX deviation END + end) + end) + it("should support an injected dispatcher", function() + local function Foo(props) + -- ROBLOX deviation START: useState returns 2 values + -- local state = React.useState("hello world")[1] + local state = React.useState("hello world") + -- ROBLOX deviation END + -- ROBLOX deviation START: use Frame instead + -- return React.createElement("div", nil, state) + return React.createElement("Frame", nil, state) + -- ROBLOX deviation END + end + local initial = {} + local current = initial + local getterCalls = 0 + local setterCalls = {} + -- ROBLOX deviation START: implement getter and setter + -- local FakeDispatcherRef = { + -- current = function(self) + -- getterCalls += 1 + -- return current + -- end, + -- current = function(self, value) + -- table.insert(setterCalls, value) --[[ ROBLOX CHECK: check if 'setterCalls' is an Array ]] + -- current = value + -- end, + -- } + local FakeDispatcherRef = setmetatable({ + __getters = { + current = function(self) + getterCalls += 1 + return current + end, + }, + __setters = { + current = function(self, value) + table.insert(setterCalls, value) + current = value + end, + }, + }, { + __index = function(self, key) + if typeof(self.__getters[key]) == "function" then + return self.__getters[key](self) + else + return nil + end + end, + __newindex = function(self, key, value) + if typeof(self.__setters[key]) == "function" then + return self.__setters[key](self, value) + else + return nil + end + end, + }) :: any + -- ROBLOX deviation END + local renderer = ReactTestRenderer.create(React.createElement(Foo, nil)) + local childFiber = renderer.root:_currentFiber() + expect(function() + -- ROBLOX deviation START: use dot notation + -- ReactDebugTools:inspectHooksOfFiber(childFiber, FakeDispatcherRef) + ReactDebugTools.inspectHooksOfFiber(childFiber, FakeDispatcherRef) + -- ROBLOX deviation END + end).toThrow( + "Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for" + .. " one of the following reasons:\n" + .. "1. You might have mismatching versions of React and the renderer (such as React DOM)\n" + .. "2. You might be breaking the Rules of Hooks\n" + .. "3. You might have more than one copy of React in the same app\n" + .. "See https://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem." + ) + expect(getterCalls).toBe(1) + expect(setterCalls).toHaveLength(2) + expect(setterCalls[ + 1 --[[ ROBLOX adaptation: added 1 to array index ]] + -- ROBLOX deviation START: use never instead of not + -- ])["not"].toBe(initial) + ]).never.toBe(initial) + -- ROBLOX deviation END + expect(setterCalls[ + 2 --[[ ROBLOX adaptation: added 1 to array index ]] + ]).toBe(initial) + end) -- This test case is based on an open source bug report: + -- facebookincubator/redux-react-hook/issues/34#issuecomment-466693787 + it("should properly advance the current hook for useContext", function() + local MyContext = React.createContext(1) + local incrementCount + local function Foo(props) + local context = React.useContext(MyContext) + -- ROBLOX deviation START: useState returns 2 values + -- local data, setData = table.unpack(React.useState({ count = context }), 1, 2) + local data, setData = React.useState({ count = context }) + -- ROBLOX deviation END + incrementCount = function() + return setData(function(ref0) + local count = ref0.count + return { count = count + 1 } + end) + end + -- ROBLOX deviation START: use FRame instead + -- return React.createElement("div", nil, "count: ", data.count) + return React.createElement("Frame", nil, "count: ", data.count) + -- ROBLOX deviation END + end + local renderer = ReactTestRenderer.create(React.createElement(Foo, nil)) + expect(renderer:toJSON()).toEqual({ + -- ROBLOX deviation START: use Frame instead + -- type = "div", + type = "Frame", + -- ROBLOX deviation END + props = {}, + children = { "count: ", "1" }, + }) + act(incrementCount) + expect(renderer:toJSON()).toEqual({ + -- ROBLOX deviation START: use Frame instead + -- type = "div", + type = "Frame", + -- ROBLOX deviation END + props = {}, + children = { "count: ", "2" }, + }) + local childFiber = renderer.root:_currentFiber() + -- ROBLOX deviation START: use dot notation + -- local tree = ReactDebugTools:inspectHooksOfFiber(childFiber) + local tree = ReactDebugTools.inspectHooksOfFiber(childFiber) + -- ROBLOX deviation END + expect(tree).toEqual({ + { + isStateEditable = false, + -- ROBLOX deviation START: Luau doesn't support mixed arrays + -- id = nil, + id = nil :: number | nil, + -- ROBLOX deviation END + name = "Context", + -- ROBLOX deviation START: Luau doesn't support mixed arrays + -- value = 1, + value = 1 :: any, + -- ROBLOX deviation END + subHooks = {}, + }, + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 0, + id = 1, + -- ROBLOX deviation END + name = "State", + value = { count = 2 }, + subHooks = {}, + }, + }) + end) + -- ROBLOX deviation START: no experimental features + -- if Boolean.toJSBoolean(__EXPERIMENTAL__) then + -- it("should support composite useMutableSource hook", function() + -- local mutableSource = React.unstable_createMutableSource({}, function() + -- return 1 + -- end) + -- local function Foo(props) + -- React.unstable_useMutableSource(mutableSource, function() + -- return "snapshot" + -- end, function() end) + -- React.useMemo(function() + -- return "memo" + -- end, {}) + -- return React.createElement("div", nil) + -- end + -- local renderer = ReactTestRenderer.create(React.createElement(Foo, nil)) + -- local childFiber = renderer.root:findByType(Foo):_currentFiber() + -- local tree = ReactDebugTools:inspectHooksOfFiber(childFiber) + -- expect(tree).toEqual({ + -- { + -- id = 0, + -- isStateEditable = false, + -- name = "MutableSource", + -- value = "snapshot", + -- subHooks = {}, + -- }, + -- { + -- id = 1, + -- isStateEditable = false, + -- name = "Memo", + -- value = "memo", + -- subHooks = {}, + -- }, + -- }) + -- end) + -- end + -- ROBLOX deviation END +end) diff --git a/packages/react-debug-tools/src/init.lua b/packages/react-debug-tools/src/init.lua new file mode 100644 index 00000000..2c07d670 --- /dev/null +++ b/packages/react-debug-tools/src/init.lua @@ -0,0 +1,19 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.2/packages/react-debug-tools/index.js +--[[* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + ]] +-- ROBLOX deviation START: simplify and re-export types +-- local Packages --[[ ROBLOX comment: must define Packages module ]] +-- local LuauPolyfill = require(Packages.LuauPolyfill) +-- local Object = LuauPolyfill.Object +-- local exports = {} +-- Object.assign(exports, require(script.src.ReactDebugTools)) +-- return exports +local reactDebugToolsModule = require(script.ReactDebugTools) +export type HooksNode = reactDebugToolsModule.HooksNode +export type HooksTree = reactDebugToolsModule.HooksTree +return reactDebugToolsModule +-- ROBLOX deviation END diff --git a/packages/react-debug-tools/wally.toml b/packages/react-debug-tools/wally.toml new file mode 100644 index 00000000..0b464323 --- /dev/null +++ b/packages/react-debug-tools/wally.toml @@ -0,0 +1,13 @@ +[package] +name = 'core-packages/react-debug-tools' +description = 'https://github.com/grilme99/CorePackages' +version = '17.0.1-rc.19' +license = 'MIT' +authors = ['Roblox Corporation'] +registry = 'https://github.com/UpliftGames/wally-index' +realm = 'shared' + +[dependencies] +LuauPolyfill = 'core-packages/luau-polyfill@1.2.3' +ReactReconciler = 'core-packages/react-reconciler@17.0.1-rc.19' +Shared = 'core-packages/shared@17.0.1-rc.19' diff --git a/packages/react-devtools-shared/default.project.json b/packages/react-devtools-shared/default.project.json new file mode 100644 index 00000000..b69bab20 --- /dev/null +++ b/packages/react-devtools-shared/default.project.json @@ -0,0 +1,6 @@ +{ + "name": "react-devtools-shared", + "tree": { + "$path": "src/" + } +} \ No newline at end of file diff --git a/packages/react-devtools-shared/src/backend/NativeStyleEditor/types.lua b/packages/react-devtools-shared/src/backend/NativeStyleEditor/types.lua new file mode 100644 index 00000000..1ccb8f05 --- /dev/null +++ b/packages/react-devtools-shared/src/backend/NativeStyleEditor/types.lua @@ -0,0 +1,29 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/backend/NativeStyleEditor/types.js +-- /** +-- * Copyright (c) Facebook, Inc. and its affiliates. +-- * +-- * This source code is licensed under the MIT license found in the +-- * LICENSE file in the root directory of this source tree. +-- * +-- * @flow +-- */ +type Object = { [string]: any } + +export type BoxStyle = { bottom: number, left: number, right: number, top: number } + +export type Layout = { + x: number, + y: number, + width: number, + height: number, + left: number, + top: number, + margin: BoxStyle, + padding: BoxStyle, +} + +export type Style = Object + +export type StyleAndLayout = { id: number, style: Style | nil, layout: Layout | nil } + +return {} diff --git a/packages/react-devtools-shared/src/backend/ReactSymbols.lua b/packages/react-devtools-shared/src/backend/ReactSymbols.lua new file mode 100644 index 00000000..d7f86f2e --- /dev/null +++ b/packages/react-devtools-shared/src/backend/ReactSymbols.lua @@ -0,0 +1,59 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/backend/ReactSymbols.js +--[[* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. +]] +local exports = {} +exports.CONCURRENT_MODE_NUMBER = 0xeacf +exports.CONCURRENT_MODE_SYMBOL_STRING = "Symbol(react.concurrent_mode)" + +exports.CONTEXT_NUMBER = 0xeace +exports.CONTEXT_SYMBOL_STRING = "Symbol(react.context)" + +exports.DEPRECATED_ASYNC_MODE_SYMBOL_STRING = "Symbol(react.async_mode)" + +exports.ELEMENT_NUMBER = 0xeac7 +exports.ELEMENT_SYMBOL_STRING = "Symbol(react.element)" + +exports.DEBUG_TRACING_MODE_NUMBER = 0xeae1 +exports.DEBUG_TRACING_MODE_SYMBOL_STRING = "Symbol(react.debug_trace_mode)" + +exports.FORWARD_REF_NUMBER = 0xead0 +exports.FORWARD_REF_SYMBOL_STRING = "Symbol(react.forward_ref)" + +exports.FRAGMENT_NUMBER = 0xeacb +exports.FRAGMENT_SYMBOL_STRING = "Symbol(react.fragment)" + +exports.LAZY_NUMBER = 0xead4 +exports.LAZY_SYMBOL_STRING = "Symbol(react.lazy)" + +exports.MEMO_NUMBER = 0xead3 +exports.MEMO_SYMBOL_STRING = "Symbol(react.memo)" + +exports.OPAQUE_ID_NUMBER = 0xeae0 +exports.OPAQUE_ID_SYMBOL_STRING = "Symbol(react.opaque.id)" + +exports.PORTAL_NUMBER = 0xeaca +exports.PORTAL_SYMBOL_STRING = "Symbol(react.portal)" + +exports.PROFILER_NUMBER = 0xead2 +exports.PROFILER_SYMBOL_STRING = "Symbol(react.profiler)" + +exports.PROVIDER_NUMBER = 0xeacd +exports.PROVIDER_SYMBOL_STRING = "Symbol(react.provider)" + +exports.SCOPE_NUMBER = 0xead7 +exports.SCOPE_SYMBOL_STRING = "Symbol(react.scope)" + +exports.STRICT_MODE_NUMBER = 0xeacc +exports.STRICT_MODE_SYMBOL_STRING = "Symbol(react.strict_mode)" + +exports.SUSPENSE_NUMBER = 0xead1 +exports.SUSPENSE_SYMBOL_STRING = "Symbol(react.suspense)" + +exports.SUSPENSE_LIST_NUMBER = 0xead8 +exports.SUSPENSE_LIST_SYMBOL_STRING = "Symbol(react.suspense_list)" + +return exports diff --git a/packages/react-devtools-shared/src/backend/agent.lua b/packages/react-devtools-shared/src/backend/agent.lua new file mode 100644 index 00000000..f140b01e --- /dev/null +++ b/packages/react-devtools-shared/src/backend/agent.lua @@ -0,0 +1,770 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/backend/agent.js +--[[* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. +]] + +local Packages = script.Parent.Parent.Parent +local LuauPolyfill = require(Packages.LuauPolyfill) +type Set = LuauPolyfill.Set +local console = LuauPolyfill.console +local JSON = game:GetService("HttpService") +local global = _G +type Function = (...any) -> ...any +type Array = { [number]: T } +type Object = { [string]: any } + +local EventEmitter = require(script.Parent.Parent.events) +type EventEmitter = EventEmitter.EventEmitter +-- ROBLOX FIXME: need to implement lodash.throttle, pass through for now +-- import throttle from 'lodash.throttle'; +local throttle = function(fn: Function, _limit: number): Function + return fn +end +local constants = require(script.Parent.Parent.constants) +local SESSION_STORAGE_LAST_SELECTION_KEY = constants.SESSION_STORAGE_LAST_SELECTION_KEY +local SESSION_STORAGE_RELOAD_AND_PROFILE_KEY = constants.SESSION_STORAGE_RELOAD_AND_PROFILE_KEY +local SESSION_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY = constants.SESSION_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY +local __DEBUG__ = constants.__DEBUG__ +local storage = require(script.Parent.Parent.storage) +local sessionStorageGetItem = storage.sessionStorageGetItem +local sessionStorageRemoveItem = storage.sessionStorageRemoveItem +local sessionStorageSetItem = storage.sessionStorageSetItem +-- local Highlighter = require(script.Parent.views.Highlighter) +-- local setupHighlighter = Highlighter.default +-- ROBLOX TODO: stub for now +local setupHighlighter = function(bridge, agent) end +-- local TraceUpdates = require(script.Parent.views.TraceUpdates) +-- local setupTraceUpdates = TraceUpdates.initialize +-- local setTraceUpdatesEnabled = TraceUpdates.toggleEnabled +-- ROBLOX TODO: stub these for now +local setupTraceUpdates = function(agent) end +local setTraceUpdatesEnabled = function(enabled: boolean) end + +-- local console = require(script.Parent.console) +-- local patchConsole = console.patch +-- local unpatchConsole = console.unpatch +-- ROBLOX TODO: stub these for now. they're used to force the debugger to break immediately when console.error is called +local patchConsole = function(obj) end +local unpatchConsole = function() end + +local Bridge = require(script.Parent.Parent.bridge) +type BackendBridge = Bridge.BackendBridge + +local BackendTypes = require(script.Parent.types) +type InstanceAndStyle = BackendTypes.InstanceAndStyle +type NativeType = BackendTypes.NativeType +type OwnersList = BackendTypes.OwnersList +type PathFrame = BackendTypes.PathFrame +type PathMatch = BackendTypes.PathMatch +type RendererID = BackendTypes.RendererID +type RendererInterface = BackendTypes.RendererInterface + +local SharedTypes = require(script.Parent.Parent.types) +type ComponentFilter = SharedTypes.ComponentFilter + +local debug_ = function(methodName, ...) + if __DEBUG__ then + -- ROBLOX deviation: simpler print + print(methodName, ...) + end +end + +type ElementAndRendererID = { id: number, rendererID: number } + +type StoreAsGlobalParams = { + count: number, + id: number, + path: Array, + rendererID: number, +} + +type CopyElementParams = { + id: number, + path: Array, + rendererID: number, +} + +type InspectElementParams = { + id: number, + path: Array?, + rendererID: number, +} + +type OverrideHookParams = { + id: number, + hookID: number, + path: Array, + rendererID: number, + wasForwarded: boolean?, + value: any, +} + +type SetInParams = { + id: number, + path: Array, + rendererID: number, + wasForwarded: boolean?, + value: any, +} + +-- ROBLOX deviation: Luau can't do literal enumerations: 'props' | 'hooks' | 'state' | 'context'; +type PathType = string + +type DeletePathParams = { + type: PathType, + hookID: number?, + id: number, + path: Array, + rendererID: number, +} + +type RenamePathParams = { + type: PathType, + hookID: number?, + id: number, + oldPath: Array, + newPath: Array, + rendererID: number, +} + +type OverrideValueAtPathParams = { + type: PathType, + hookID: number?, + id: number, + path: Array, + rendererID: number, + value: any, +} + +type OverrideSuspenseParams = { id: number, rendererID: number, forceFallback: boolean } + +type PersistedSelection = { rendererID: number, path: Array } + +export type Agent = EventEmitter<{ + hideNativeHighlight: Array, + showNativeHighlight: Array, + shutdown: any, + traceUpdates: Set, +}> & { + _bridge: BackendBridge, + _isProfiling: boolean, + _recordChangeDescriptions: boolean, + _rendererInterfaces: { [RendererID]: RendererInterface }, + _persistedSelection: PersistedSelection | nil, + _persistedSelectionMatch: PathMatch | nil, + _traceUpdatesEnabled: boolean, + + getRendererInterfaces: (self: Agent) -> { [RendererID]: RendererInterface }, + copyElementPath: (self: Agent, copyElementParams: CopyElementParams) -> (), + deletePath: (self: Agent, deletePathParams: DeletePathParams) -> (), + getInstanceAndStyle: (self: Agent, elementAndRendererId: ElementAndRendererID) -> InstanceAndStyle | nil, + getIDForNode: (self: Agent, node: Object) -> number | nil, + getProfilingData: (self: Agent, rendererIdObject: { rendererID: RendererID }) -> (), + getProfilingStatus: (self: Agent) -> (), + getOwnersList: (self: Agent, elementAndRendererID: ElementAndRendererID) -> (), + inspectElement: (self: Agent, inspectElementParams: InspectElementParams) -> (), + logElementToConsole: (self: Agent, elementAndRendererID: ElementAndRendererID) -> (), + overrideSuspense: (self: Agent, overrideSuspenseParams: OverrideSuspenseParams) -> (), + overrideValueAtPath: (self: Agent, overrideValueAtPathParams: OverrideValueAtPathParams) -> (), + overrideContext: (self: Agent, setInParams: SetInParams) -> (), + overrideHookState: (self: Agent, overrideHookParams: OverrideHookParams) -> (), + overrideProps: (self: Agent, setInParams: SetInParams) -> (), + overrideState: (self: Agent, setInParams: SetInParams) -> (), + reloadAndProfile: (self: Agent, recordChangeDescriptions: boolean) -> (), + renamePath: (self: Agent, renamePathParams: RenamePathParams) -> (), + selectNode: (self: Agent, target: Object) -> (), + setRendererInterface: (self: Agent, rendererID: number, rendererInterface: RendererInterface) -> (), + setTraceUpdatesEnabled: (self: Agent, traceUpdatesEnabled: boolean) -> (), + syncSelectionFromNativeElementsPanel: (self: Agent) -> (), + shutdown: (self: Agent) -> (), + startProfiling: (self: Agent, recordChangeDescriptions: boolean) -> (), + stopProfiling: (self: Agent) -> (), + storeAsGlobal: (self: Agent, storeAsGlobalParams: StoreAsGlobalParams) -> (), + updateConsolePatchSettings: ( + self: Agent, + _ref16: { appendComponentStack: boolean, breakOnConsoleErrors: boolean } + ) -> (), + updateComponentFilters: (self: Agent, componentFilters: Array) -> (), + viewAttributeSource: (self: Agent, copyElementParams: CopyElementParams) -> (), + viewElementSource: (self: Agent, elementAndRendererID: ElementAndRendererID) -> (), + onTraceUpdates: (self: Agent, nodes: Set) -> (), + onHookOperations: (self: Agent, operations: Array) -> (), + onUnsupportedRenderer: (self: Agent, rendererID: number) -> (), + + _throttledPersistSelection: (self: Agent, rendererID: number, id: number) -> (), +} + +type Agent_Statics = { + new: (bridge: BackendBridge) -> Agent, +} + +local Agent: Agent & Agent_Statics = setmetatable({}, { __index = EventEmitter }) :: any + +local AgentMetatable = { __index = Agent } +-- ROBLOX deviation: equivalent of sub-class + +function Agent.new(bridge: BackendBridge) + local self = setmetatable(EventEmitter.new() :: any, AgentMetatable) + + -- ROBLOX deviation: define fields in constructor + self._bridge = bridge + self._isProfiling = false + self._recordChangeDescriptions = false + self._rendererInterfaces = {} + self._persistedSelection = nil + self._persistedSelectionMatch = nil + self._traceUpdatesEnabled = false + + if sessionStorageGetItem(SESSION_STORAGE_RELOAD_AND_PROFILE_KEY) == "true" then + self._recordChangeDescriptions = sessionStorageGetItem(SESSION_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY) == "true" + self._isProfiling = true + + sessionStorageRemoveItem(SESSION_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY) + sessionStorageRemoveItem(SESSION_STORAGE_RELOAD_AND_PROFILE_KEY) + end + + local persistedSelectionString = sessionStorageGetItem(SESSION_STORAGE_LAST_SELECTION_KEY) + + if persistedSelectionString ~= nil then + self._persistedSelection = JSON.JSONDecode(persistedSelectionString) + end + + local function wrapSelf(method: Function) + return function(...) + method(self, ...) + end + end + + bridge:addListener("copyElementPath", wrapSelf(self.copyElementPath)) + bridge:addListener("deletePath", wrapSelf(self.deletePath)) + bridge:addListener("getProfilingData", wrapSelf(self.getProfilingData)) + bridge:addListener("getProfilingStatus", wrapSelf(self.getProfilingStatus)) + bridge:addListener("getOwnersList", wrapSelf(self.getOwnersList)) + bridge:addListener("inspectElement", wrapSelf(self.inspectElement)) + bridge:addListener("logElementToConsole", wrapSelf(self.logElementToConsole)) + bridge:addListener("overrideSuspense", wrapSelf(self.overrideSuspense)) + bridge:addListener("overrideValueAtPath", wrapSelf(self.overrideValueAtPath)) + bridge:addListener("reloadAndProfile", wrapSelf(self.reloadAndProfile)) + bridge:addListener("renamePath", wrapSelf(self.renamePath)) + bridge:addListener("setTraceUpdatesEnabled", wrapSelf(self.setTraceUpdatesEnabled)) + bridge:addListener("startProfiling", wrapSelf(self.startProfiling)) + bridge:addListener("stopProfiling", wrapSelf(self.stopProfiling)) + bridge:addListener("storeAsGlobal", wrapSelf(self.storeAsGlobal)) + bridge:addListener("syncSelectionFromNativeElementsPanel", wrapSelf(self.syncSelectionFromNativeElementsPanel)) + bridge:addListener("shutdown", wrapSelf(self.shutdown)) + bridge:addListener("updateConsolePatchSettings", wrapSelf(self.updateConsolePatchSettings)) + bridge:addListener("updateComponentFilters", wrapSelf(self.updateComponentFilters)) + bridge:addListener("viewAttributeSource", wrapSelf(self.viewAttributeSource)) + bridge:addListener("viewElementSource", wrapSelf(self.viewElementSource)) + + -- Temporarily support older standalone front-ends sending commands to newer embedded backends. + -- We do this because React Native embeds the React DevTools backend, + -- but cannot control which version of the frontend users use. + bridge:addListener("overrideContext", wrapSelf(self.overrideContext)) + bridge:addListener("overrideHookState", wrapSelf(self.overrideHookState)) + bridge:addListener("overrideProps", wrapSelf(self.overrideProps)) + bridge:addListener("overrideState", wrapSelf(self.overrideState)) + + if self._isProfiling then + bridge:send("profilingStatus", true) + end + + -- Notify the frontend if the backend supports the Storage API (e.g. localStorage). + -- If not, features like reload-and-profile will not work correctly and must be disabled. + -- ROBLOX deviation: Storage is supported, but we don't use localStorage per se + local isBackendStorageAPISupported = true + + bridge:send("isBackendStorageAPISupported", isBackendStorageAPISupported) + -- ROBLOX TODO: implement Highlighter stub + setupHighlighter(bridge, self) + setupTraceUpdates(self) + + return self +end + +-- ROBLOX FIXME: this needs to be a property getter via an __index override +function Agent:getRendererInterfaces() + return self._rendererInterfaces +end + +function Agent:copyElementPath(copyElementParams: CopyElementParams): () + local id, path, rendererID = copyElementParams.id, copyElementParams.path, copyElementParams.rendererID + local renderer = self._rendererInterfaces[rendererID] + + if renderer == nil then + console.warn(string.format('Invalid renderer id "%d" for element "%d"', rendererID, id)) + else + (renderer :: RendererInterface).copyElementPath(id, path) + end +end +function Agent:deletePath(deletePathParams: DeletePathParams): () + local hookID, id, path, rendererID, type_ = + deletePathParams.hookID, + deletePathParams.id, + deletePathParams.path, + deletePathParams.rendererID, + deletePathParams.type + local renderer = self._rendererInterfaces[rendererID] + + if renderer == nil then + console.warn(string.format('Invalid renderer id "%d" for element "%d"', rendererID, id)) + else + (renderer :: RendererInterface).deletePath(type_, id, hookID, path) + end +end +function Agent:getInstanceAndStyle(elementAndRendererId: ElementAndRendererID): InstanceAndStyle | nil + local id, rendererID = elementAndRendererId.id, elementAndRendererId.rendererID + local renderer = self._rendererInterfaces[rendererID] + + if renderer == nil then + console.warn(string.format('Invalid renderer id "%d"', rendererID)) + return nil + end + + return (renderer :: RendererInterface).getInstanceAndStyle(id) +end + +function Agent:getIDForNode(node: Object): number | nil + for _rendererID, renderer in self._rendererInterfaces do + local ok, result = pcall(renderer.getFiberIDForNative, node, true) + if ok and result ~= nil then + return result + end + -- Some old React versions might throw if they can't find a match. + -- If so we should ignore it... + end + return nil +end +function Agent:getProfilingData(rendererIdObject: { rendererID: RendererID }): () + local rendererID = rendererIdObject.rendererID + local renderer = self._rendererInterfaces[rendererID] + + if renderer == nil then + console.warn(string.format('Invalid renderer id "%d"', rendererID)) + end + + self._bridge:send("profilingData", (renderer :: RendererInterface).getProfilingData()) +end +function Agent:getProfilingStatus() + self._bridge:send("profilingStatus", self._isProfiling) +end +function Agent:getOwnersList(elementAndRendererID: ElementAndRendererID) + local id, rendererID = elementAndRendererID.id, elementAndRendererID.rendererID + local renderer = self._rendererInterfaces[rendererID] + + if renderer == nil then + console.warn(string.format('Invalid renderer id "%d" for element "%d"', rendererID, id)) + else + local owners = (renderer :: RendererInterface).getOwnersList(id) + + self._bridge:send("ownersList", { + id = id, + owners = owners, + }) + end +end +function Agent:inspectElement(inspectElementParams: InspectElementParams) + local id, path, rendererID = inspectElementParams.id, inspectElementParams.path, inspectElementParams.rendererID + local renderer = self._rendererInterfaces[rendererID] + + if renderer == nil then + console.warn(string.format('Invalid renderer id "%d" for element "%d"', rendererID, id)) + else + self._bridge:send("inspectedElement", (renderer :: RendererInterface).inspectElement(id, path)) + + -- When user selects an element, stop trying to restore the selection, + -- and instead remember the current selection for the next reload. + if + (self._persistedSelectionMatch :: PathMatch?) == nil + or (self._persistedSelectionMatch :: PathMatch).id ~= id + then + self._persistedSelection = nil + self._persistedSelectionMatch = nil; + + (renderer :: RendererInterface).setTrackedPath(nil) + self:_throttledPersistSelection(rendererID, id) + end + + -- TODO: If there was a way to change the selected DOM element + -- in native Elements tab without forcing a switch to it, we'd do it here. + -- For now, it doesn't seem like there is a way to do that: + -- https://github.com/bvaughn/react-devtools-experimental/issues/102 + -- (Setting $0 doesn't work, and calling inspect() switches the tab.) + end +end +function Agent:logElementToConsole(elementAndRendererID: ElementAndRendererID) + local id, rendererID = elementAndRendererID.id, elementAndRendererID.rendererID + local renderer = self._rendererInterfaces[rendererID] + + if renderer == nil then + console.warn(string.format('Invalid renderer id "%d" for element "%d"', rendererID, id)) + else + (renderer :: RendererInterface).logElementToConsole(id) + end +end +function Agent:overrideSuspense(overrideSuspenseParams: OverrideSuspenseParams) + local id, rendererID, forceFallback = + overrideSuspenseParams.id, overrideSuspenseParams.rendererID, overrideSuspenseParams.forceFallback + local renderer = self._rendererInterfaces[rendererID] + + if renderer == nil then + console.warn(string.format('Invalid renderer id "%d" for element "%d"', rendererID, id)) + else + (renderer :: RendererInterface).overrideSuspense(id, forceFallback) + end +end +function Agent:overrideValueAtPath(overrideValueAtPathParams: OverrideValueAtPathParams) + local hookID, id, path, rendererID, type_, value = + overrideValueAtPathParams.hookID, + overrideValueAtPathParams.id, + overrideValueAtPathParams.path, + overrideValueAtPathParams.rendererID, + overrideValueAtPathParams.type, + overrideValueAtPathParams.value + local renderer = self._rendererInterfaces[rendererID] + + if renderer == nil then + console.warn(string.format('Invalid renderer id "%d" for element "%d"', rendererID, id)) + else + (renderer :: RendererInterface).overrideValueAtPath(type_, id, hookID, path, value) + end +end + +-- Temporarily support older standalone front-ends by forwarding the older message types +-- to the new "overrideValueAtPath" command the backend is now listening to. +function Agent:overrideContext(setInParams: SetInParams) + local id, path, rendererID, wasForwarded, value = + setInParams.id, setInParams.path, setInParams.rendererID, setInParams.wasForwarded, setInParams.value + + -- Don't forward a message that's already been forwarded by the front-end Bridge. + -- We only need to process the override command once! + if not wasForwarded then + self:overrideValueAtPath({ + id = id, + path = path, + rendererID = rendererID, + type = "context", + value = value, + }) + end +end + +-- Temporarily support older standalone front-ends by forwarding the older message types +-- to the new "overrideValueAtPath" command the backend is now listening to. +function Agent:overrideHookState(overrideHookParams: OverrideHookParams) + local id, _hookID, path, rendererID, wasForwarded, value = + overrideHookParams.id, + overrideHookParams.hookID, + overrideHookParams.path, + overrideHookParams.rendererID, + overrideHookParams.wasForwarded, + overrideHookParams.value + + -- Don't forward a message that's already been forwarded by the front-end Bridge. + -- We only need to process the override command once! + if not wasForwarded then + self:overrideValueAtPath({ + id = id, + path = path, + rendererID = rendererID, + type = "hooks", + value = value, + }) + end +end + +-- Temporarily support older standalone front-ends by forwarding the older message types +-- to the new "overrideValueAtPath" command the backend is now listening to. +function Agent:overrideProps(setInParams: SetInParams) + local id, path, rendererID, wasForwarded, value = + setInParams.id, setInParams.path, setInParams.rendererID, setInParams.wasForwarded, setInParams.value + + -- Don't forward a message that's already been forwarded by the front-end Bridge. + -- We only need to process the override command once! + if not wasForwarded then + self:overrideValueAtPath({ + id = id, + path = path, + rendererID = rendererID, + type = "props", + value = value, + }) + end +end + +-- Temporarily support older standalone front-ends by forwarding the older message types +-- to the new "overrideValueAtPath" command the backend is now listening to. +function Agent:overrideState(setInParams: SetInParams) + local id, path, rendererID, wasForwarded, value = + setInParams.id, setInParams.path, setInParams.rendererID, setInParams.wasForwarded, setInParams.value + + -- Don't forward a message that's already been forwarded by the front-end Bridge. + -- We only need to process the override command once! + if not wasForwarded then + self:overrideValueAtPath({ + id = id, + path = path, + rendererID = rendererID, + type = "state", + value = value, + }) + end +end +function Agent:reloadAndProfile(recordChangeDescriptions: boolean) + sessionStorageSetItem(SESSION_STORAGE_RELOAD_AND_PROFILE_KEY, "true") + sessionStorageSetItem( + SESSION_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY, + (function() + if recordChangeDescriptions then + return "true" + end + + return "false" + end)() + ) + + -- This code path should only be hit if the shell has explicitly told the Store that it supports profiling. + -- In that case, the shell must also listen for this specific message to know when it needs to reload the app. + -- The agent can't do this in a way that is renderer agnostic. + self._bridge:send("reloadAppForProfiling") +end +function Agent:renamePath(renamePathParams: RenamePathParams) + local hookID, id, newPath, oldPath, rendererID, type_ = + renamePathParams.hookID, + renamePathParams.id, + renamePathParams.newPath, + renamePathParams.oldPath, + renamePathParams.rendererID, + renamePathParams.type + local renderer = self._rendererInterfaces[rendererID] + + if renderer == nil then + console.warn(string.format('Invalid renderer id "%d" for element "%d"', rendererID, id)) + else + (renderer :: RendererInterface).renamePath(type_, id, hookID, oldPath, newPath) + end +end +function Agent:selectNode(target: Object): () + local id = self:getIDForNode(target) + + if id ~= nil then + self._bridge:send("selectFiber", id) + end +end +function Agent:setRendererInterface(rendererID: number, rendererInterface: RendererInterface) + self._rendererInterfaces[rendererID] = rendererInterface + + if self._isProfiling then + rendererInterface.startProfiling(self._recordChangeDescriptions) + end + + rendererInterface.setTraceUpdatesEnabled(self._traceUpdatesEnabled) + + -- When the renderer is attached, we need to tell it whether + -- we remember the previous selection that we'd like to restore. + -- It'll start tracking mounts for matches to the last selection path. + local selection: PersistedSelection? = self._persistedSelection + + if selection ~= nil and (selection :: PersistedSelection).rendererID == rendererID then + rendererInterface.setTrackedPath((selection :: PersistedSelection).path) + end +end +function Agent:setTraceUpdatesEnabled(traceUpdatesEnabled: boolean) + self._traceUpdatesEnabled = traceUpdatesEnabled + + setTraceUpdatesEnabled(traceUpdatesEnabled) + + for _rendererID, renderer in self._rendererInterfaces do + renderer.setTraceUpdatesEnabled(traceUpdatesEnabled) + end +end +function Agent:syncSelectionFromNativeElementsPanel() + local target = global.__REACT_DEVTOOLS_GLOBAL_HOOK__["$0"] + + if target == nil then + return + end + + self:selectNode(target) +end +function Agent:shutdown() + -- Clean up the overlay if visible, and associated events. + self:emit("shutdown") +end +function Agent:startProfiling(recordChangeDescriptions: boolean) + self._recordChangeDescriptions = recordChangeDescriptions + self._isProfiling = true + + for _rendererID, renderer in self._rendererInterfaces do + renderer.startProfiling(recordChangeDescriptions) + end + + self._bridge:send("profilingStatus", self._isProfiling) +end +function Agent:stopProfiling() + self._isProfiling = false + self._recordChangeDescriptions = false + + for _rendererID, renderer in self._rendererInterfaces do + renderer.stopProfiling() + end + + self._bridge:send("profilingStatus", self._isProfiling) +end + +function Agent:storeAsGlobal(storeAsGlobalParams: StoreAsGlobalParams) + local count, id, path, rendererID = + storeAsGlobalParams.count, storeAsGlobalParams.id, storeAsGlobalParams.path, storeAsGlobalParams.rendererID + local renderer = self._rendererInterfaces[rendererID] + + if renderer == nil then + console.warn(string.format('Invalid renderer id "%d" for element "%d"', rendererID, id)) + else + (renderer :: RendererInterface).storeAsGlobal(id, path, count) + end +end + +function Agent:updateConsolePatchSettings(_ref16: { + appendComponentStack: boolean, + breakOnConsoleErrors: boolean, +}) + local appendComponentStack, breakOnConsoleErrors = _ref16.appendComponentStack, _ref16.breakOnConsoleErrors + + -- If the frontend preference has change, + -- or in the case of React Native- if the backend is just finding out the preference- + -- then install or uninstall the console overrides. + -- It's safe to call these methods multiple times, so we don't need to worry about that. + if appendComponentStack or breakOnConsoleErrors then + patchConsole({ + appendComponentStack = appendComponentStack, + breakOnConsoleErrors = breakOnConsoleErrors, + }) + else + unpatchConsole() + end +end +function Agent:updateComponentFilters(componentFilters: Array) + for _rendererID, renderer in self._rendererInterfaces do + renderer.updateComponentFilters(componentFilters) + end +end +function Agent:viewAttributeSource(copyElementParams: CopyElementParams) + local id, path, rendererID = copyElementParams.id, copyElementParams.path, copyElementParams.rendererID + local renderer = self._rendererInterfaces[rendererID] + + if renderer == nil then + console.warn(string.format('Invalid renderer id "%d" for element "%d"', rendererID, id)) + else + (renderer :: RendererInterface).prepareViewAttributeSource(id, path) + end +end +function Agent:viewElementSource(elementAndRendererID: ElementAndRendererID) + local id, rendererID = elementAndRendererID.id, elementAndRendererID.rendererID + local renderer = self._rendererInterfaces[rendererID] + + if renderer == nil then + console.warn(string.format('Invalid renderer id "%d" for element "%d"', rendererID, id)) + else + (renderer :: RendererInterface).prepareViewElementSource(id) + end +end +function Agent:onTraceUpdates(nodes: Set) + self:emit("traceUpdates", nodes) +end +function Agent:onHookOperations(operations: Array) + if global.__DEBUG__ then + debug_("onHookOperations", operations) + end + + -- TODO: + -- The chrome.runtime does not currently support transferables; it forces JSON serialization. + -- See bug https://bugs.chromium.org/p/chromium/issues/detail?id=927134 + -- + -- Regarding transferables, the postMessage doc states: + -- If the ownership of an object is transferred, it becomes unusable (neutered) + -- in the context it was sent from and becomes available only to the worker it was sent to. + -- + -- Even though Chrome is eventually JSON serializing the array buffer, + -- using the transferable approach also sometimes causes it to throw: + -- DOMException: Failed to execute 'postMessage' on 'Window': ArrayBuffer at index 0 is already neutered. + -- + -- See bug https://github.com/bvaughn/react-devtools-experimental/issues/25 + -- + -- The Store has a fallback in place that parses the message as JSON if the type isn't an array. + -- For now the simplest fix seems to be to not transfer the array. + -- This will negatively impact performance on Firefox so it's unfortunate, + -- but until we're able to fix the Chrome error mentioned above, it seems necessary. + -- + self._bridge:send("operations", operations) + + if self._persistedSelection ~= nil then + local rendererID = operations[1] + + if (self._persistedSelection :: PersistedSelection).rendererID == rendererID then + -- Check if we can select a deeper match for the persisted selection. + local renderer = self._rendererInterfaces[rendererID] + + if renderer == nil then + console.warn(string.format('Invalid renderer id "%d"', rendererID)) + else + local prevMatch = self._persistedSelectionMatch + local nextMatch = (renderer :: RendererInterface).getBestMatchForTrackedPath() + + self._persistedSelectionMatch = nextMatch + + local prevMatchID = if prevMatch ~= nil then prevMatch.id else nil + local nextMatchID = if nextMatch ~= nil then nextMatch.id else nil + + if prevMatchID ~= nextMatchID then + if nextMatchID ~= nil then + -- We moved forward, unlocking a deeper node. + self._bridge:send("selectFiber", nextMatchID) + end + end + if nextMatch ~= nil and (nextMatch :: PathMatch).isFullMatch then + -- We've just unlocked the innermost selected node. + -- There's no point tracking it further. + self._persistedSelection = nil + self._persistedSelectionMatch = nil; + + (renderer :: RendererInterface).setTrackedPath(nil) + end + end + end + end +end + +function Agent:onUnsupportedRenderer(rendererID: number) + self._bridge:send("unsupportedRendererVersion", rendererID) +end + +Agent._throttledPersistSelection = throttle(function(self, rendererID: number, id: number) + -- This is throttled, so both renderer and selected ID + -- might not be available by the time we read them. + -- This is why we need the defensive checks here. + local renderer = self._rendererInterfaces[rendererID] + local path = (function() + if renderer ~= nil then + return (renderer :: RendererInterface).getPathForElement(id) + end + + return nil + end)() + + if path ~= nil then + sessionStorageSetItem( + SESSION_STORAGE_LAST_SELECTION_KEY, + JSON:JSONEncode({ + rendererID = rendererID, + path = path, + }) + ) + else + sessionStorageRemoveItem(SESSION_STORAGE_LAST_SELECTION_KEY) + end +end, 1000) + +return Agent diff --git a/packages/react-devtools-shared/src/backend/console.lua b/packages/react-devtools-shared/src/backend/console.lua new file mode 100644 index 00000000..e1581fab --- /dev/null +++ b/packages/react-devtools-shared/src/backend/console.lua @@ -0,0 +1,38 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/backend/console.js +-- /** +-- * Copyright (c) Facebook, Inc. and its affiliates. +-- * +-- * This source code is licensed under the MIT license found in the +-- * LICENSE file in the root directory of this source tree. +-- * +-- * @flow +-- */ + +local Types = require(script.Parent.types) +type ReactRenderer = Types.ReactRenderer + +local exports = {} + +-- ROBLOX FIXME: Stub for now +function exports.patch(_object: { + appendComponentStack: boolean, + breakOnConsoleErrors: boolean, +}): () end + +function exports.unpatch(): () end + +function exports.error(...) + error(...) +end + +function exports.warn(...) + warn(...) +end + +function exports.log(...) + print(...) +end + +function exports.registerRenderer(_renderer: ReactRenderer): () end + +return exports diff --git a/packages/react-devtools-shared/src/backend/init.lua b/packages/react-devtools-shared/src/backend/init.lua new file mode 100644 index 00000000..8bc63eb7 --- /dev/null +++ b/packages/react-devtools-shared/src/backend/init.lua @@ -0,0 +1,138 @@ +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/backend/index.js +--[[* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. +]] + +local Packages = script.Parent.Parent +local LuauPolyfill = require(Packages.LuauPolyfill) +local Array = LuauPolyfill.Array + +local Agent = require(script.agent) +type Agent = Agent.Agent + +local types = require(script.types) +export type DevToolsHook = types.DevToolsHook +export type ReactRenderer = types.ReactRenderer +export type RendererInterface = types.RendererInterface + +type Object = { [string]: any } + +local function initBackend(hook: DevToolsHook, agent: Agent, global: Object): () -> () + if hook == nil then + -- DevTools didn't get injected into this page (maybe b'c of the contentType). + return function() end + end + local subs = { + hook.sub("renderer-attached", function(args: { + id: number, + renderer: ReactRenderer, + rendererInterface: RendererInterface, + }) + local id = args.id + local rendererInterface = args.rendererInterface + + agent:setRendererInterface(id, rendererInterface) + + -- Now that the Store and the renderer interface are connected, + -- it's time to flush the pending operation codes to the frontend. + rendererInterface.flushInitialOperations() + end), + hook.sub("unsupported-renderer-version", function(id: number) + agent:onUnsupportedRenderer(id) + end), + + hook.sub("operations", function(...) + agent:onHookOperations(...) + end), + hook.sub("traceUpdates", function(...) + agent:onTraceUpdates(...) + end), + + -- TODO Add additional subscriptions required for profiling mode + } + + local attachRenderer = function(id: number, renderer: ReactRenderer) + -- ROBLOX deviation: require attach lazily to avoid the require of renderer causing Roact to initialize prematurely. + local attach = require(script.renderer).attach + + local rendererInterface = hook.rendererInterfaces:get(id) + + -- Inject any not-yet-injected renderers (if we didn't reload-and-profile) + if rendererInterface == nil then + if type(renderer.findFiberByHostInstance) == "function" then + -- react-reconciler v16+ + rendererInterface = attach(hook, id, renderer, global) + elseif renderer.ComponentTree then + -- react-dom v15 + -- ROBLOX deviation: Not needed + -- rendererInterface = attachLegacy(hook, id, renderer, global) + else + -- Older react-dom or other unsupported renderer version + end + if rendererInterface ~= nil then + hook.rendererInterfaces:set(id, rendererInterface) + end + end + + -- Notify the DevTools frontend about new renderers. + -- This includes any that were attached early (via __REACT_DEVTOOLS_ATTACH__). + if rendererInterface ~= nil then + hook.emit("renderer-attached", { + id = id, + renderer = renderer, + rendererInterface = rendererInterface, + }) + else + hook.emit("unsupported-renderer-version", id) + end + end + + -- Connect renderers that have already injected themselves. + hook.renderers:forEach(function(renderer, id) + attachRenderer(id, renderer) + end) + + -- Connect any new renderers that injected themselves. + table.insert( + subs, + hook.sub("renderer", function(args: { id: number, renderer: ReactRenderer }) + local id = args.id + local renderer = args.renderer + attachRenderer(id, renderer) + end) + ) + + hook.emit("react-devtools", agent) + hook.reactDevtoolsAgent = agent + local function onAgentShutdown() + Array.forEach(subs, function(fn) + fn() + end) + hook.rendererInterfaces:forEach(function(rendererInterface) + rendererInterface.cleanup() + end) + hook.reactDevtoolsAgent = nil + end + agent:addListener("shutdown", onAgentShutdown) + table.insert(subs, function() + agent:removeListener("shutdown", onAgentShutdown) + end) + + return function() + for _, fn in subs do + fn() + end + end +end + +return { + initBackend = initBackend, + agent = require(script.agent), + NativeStyleEditor = { + types = require(script.NativeStyleEditor.types), + }, +} diff --git a/packages/react-devtools-shared/src/backend/renderer.lua b/packages/react-devtools-shared/src/backend/renderer.lua new file mode 100644 index 00000000..e89d16ea --- /dev/null +++ b/packages/react-devtools-shared/src/backend/renderer.lua @@ -0,0 +1,3331 @@ +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/backend/renderer.js +--[[* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. +]] + +local Packages = script.Parent.Parent.Parent + +local LuauPolyfill = require(Packages.LuauPolyfill) +local Shared = require(Packages.Shared) +local console = Shared.console +local Map = LuauPolyfill.Map +local Set = LuauPolyfill.Set +local Array = LuauPolyfill.Array +local Boolean = LuauPolyfill.Boolean +local Object = LuauPolyfill.Object +local Number = LuauPolyfill.Number +local String = LuauPolyfill.String +type Symbol = any + +type Array = LuauPolyfill.Array +type Map = LuauPolyfill.Map +type Set = LuauPolyfill.Set +type Object = LuauPolyfill.Object + +-- ROBLOX deviation: Use _G as a catch all for global for now +-- ROBLOX TODO: Work out a better capability-based solution +local window = _G +local exports = {} + +local invariant = require(Packages.Shared).invariant + +-- ROBLOX deviation: we don't currently need semver, as we only support one version of React +-- local semver = require(semver) +-- local gte = semver.gte +local types = require(script.Parent.Parent.types) +local ComponentFilterDisplayName = types.ComponentFilterDisplayName +local ComponentFilterElementType = types.ComponentFilterElementType +local ComponentFilterHOC = types.ComponentFilterHOC +local ComponentFilterLocation = types.ComponentFilterLocation +local ElementTypeClass = types.ElementTypeClass +local ElementTypeContext = types.ElementTypeContext +local ElementTypeFunction = types.ElementTypeFunction +local ElementTypeForwardRef = types.ElementTypeForwardRef +local ElementTypeHostComponent = types.ElementTypeHostComponent +local ElementTypeMemo = types.ElementTypeMemo +local ElementTypeOtherOrUnknown = types.ElementTypeOtherOrUnknown +local ElementTypeProfiler = types.ElementTypeProfiler +local ElementTypeRoot = types.ElementTypeRoot +local ElementTypeSuspense = types.ElementTypeSuspense +local ElementTypeSuspenseList = types.ElementTypeSuspenseList +local utils = require(script.Parent.Parent.utils) +local deletePathInObject = utils.deletePathInObject +local getDisplayName = utils.getDisplayName +local getDefaultComponentFilters = utils.getDefaultComponentFilters +local getInObject = utils.getInObject +local getUID = utils.getUID +local renamePathInObject = utils.renamePathInObject +local setInObject = utils.setInObject +-- ROBLOX deviation: Don't encode strings +-- local utfEncodeString = utils.utfEncodeString +local storage = require(script.Parent.Parent.storage) +local sessionStorageGetItem = storage.sessionStorageGetItem +local backendUtils = require(script.Parent.utils) +local cleanForBridge = backendUtils.cleanForBridge +local copyToClipboard = backendUtils.copyToClipboard +local copyWithDelete = backendUtils.copyWithDelete +local copyWithRename = backendUtils.copyWithRename +local copyWithSet = backendUtils.copyWithSet +local constants = require(script.Parent.Parent.constants) +local __DEBUG__ = constants.__DEBUG__ +local SESSION_STORAGE_RELOAD_AND_PROFILE_KEY = constants.SESSION_STORAGE_RELOAD_AND_PROFILE_KEY +local SESSION_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY = constants.SESSION_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY +local TREE_OPERATION_ADD = constants.TREE_OPERATION_ADD +local TREE_OPERATION_REMOVE = constants.TREE_OPERATION_REMOVE +local TREE_OPERATION_REORDER_CHILDREN = constants.TREE_OPERATION_REORDER_CHILDREN +local TREE_OPERATION_UPDATE_TREE_BASE_DURATION = constants.TREE_OPERATION_UPDATE_TREE_BASE_DURATION +local ReactDebugTools = require(Packages.ReactDebugTools) +local inspectHooksOfFiber = ReactDebugTools.inspectHooksOfFiber +local Console = require(script.Parent.console) +local patchConsole = Console.patch +local registerRendererWithConsole = Console.registerRenderer +local ReactSymbols = require(script.Parent.ReactSymbols) +local CONCURRENT_MODE_NUMBER = ReactSymbols.CONCURRENT_MODE_NUMBER +local CONCURRENT_MODE_SYMBOL_STRING = ReactSymbols.CONCURRENT_MODE_SYMBOL_STRING +local DEPRECATED_ASYNC_MODE_SYMBOL_STRING = ReactSymbols.DEPRECATED_ASYNC_MODE_SYMBOL_STRING +local PROVIDER_NUMBER = ReactSymbols.PROVIDER_NUMBER +local PROVIDER_SYMBOL_STRING = ReactSymbols.PROVIDER_SYMBOL_STRING +local CONTEXT_NUMBER = ReactSymbols.CONTEXT_NUMBER +local CONTEXT_SYMBOL_STRING = ReactSymbols.CONTEXT_SYMBOL_STRING +local STRICT_MODE_NUMBER = ReactSymbols.STRICT_MODE_NUMBER +local STRICT_MODE_SYMBOL_STRING = ReactSymbols.STRICT_MODE_SYMBOL_STRING +local PROFILER_NUMBER = ReactSymbols.PROFILER_NUMBER +local PROFILER_SYMBOL_STRING = ReactSymbols.PROFILER_SYMBOL_STRING +local SCOPE_NUMBER = ReactSymbols.SCOPE_NUMBER +local SCOPE_SYMBOL_STRING = ReactSymbols.SCOPE_SYMBOL_STRING +local FORWARD_REF_NUMBER = ReactSymbols.FORWARD_REF_NUMBER +local FORWARD_REF_SYMBOL_STRING = ReactSymbols.FORWARD_REF_SYMBOL_STRING +local MEMO_NUMBER = ReactSymbols.MEMO_NUMBER +local MEMO_SYMBOL_STRING = ReactSymbols.MEMO_SYMBOL_STRING +local is = Shared.objectIs +-- ROBLOX FIXME: pass in a real host config, or make this able to use basic enums without initializing +local ReactReconciler = require(Packages.ReactReconciler)({}) + +-- ROBLOX deviation: Require shared functionality rather than copying and pasting it inline +local getNearestMountedFiber = ReactReconciler.getNearestMountedFiber + +-- ROBLOX deviation: ReactInternalTypes is re-exported from top-level reconciler to respect the module encapsulation boundary +local ReactInternalTypes = require(Packages.ReactReconciler) +type Fiber = ReactInternalTypes.Fiber +local BackendTypes = require(script.Parent.types) +type ChangeDescription = BackendTypes.ChangeDescription +type CommitDataBackend = BackendTypes.CommitDataBackend +type DevToolsHook = BackendTypes.DevToolsHook +type InspectedElement = BackendTypes.InspectedElement +type InspectedElementPayload = BackendTypes.InspectedElementPayload +type InstanceAndStyle = BackendTypes.InstanceAndStyle +type NativeType = BackendTypes.NativeType +type Owner = BackendTypes.Owner +type PathFrame = BackendTypes.PathFrame +type PathMatch = BackendTypes.PathMatch +type ProfilingDataBackend = BackendTypes.ProfilingDataBackend +type ProfilingDataForRootBackend = BackendTypes.ProfilingDataForRootBackend +type ReactRenderer = BackendTypes.ReactRenderer +type RendererInterface = BackendTypes.RendererInterface +type WorkTagMap = BackendTypes.WorkTagMap + +local ProfilerTypes = require(script.Parent.Parent.devtools.views.Profiler.types) +type Interaction = ProfilerTypes.Interaction +local TypesModules = require(script.Parent.Parent.types) +type ComponentFilter = TypesModules.ComponentFilter +type ElementType = TypesModules.ElementType + +type RegExpComponentFilter = TypesModules.RegExpComponentFilter +type ElementTypeComponentFilter = TypesModules.ElementTypeComponentFilter + +type getDisplayNameForFiberType = (fiber: Fiber) -> string | nil +type getTypeSymbolType = (type: any) -> Symbol | number + +type ReactPriorityLevelsType = { + ImmediatePriority: number, + UserBlockingPriority: number, + NormalPriority: number, + LowPriority: number, + IdlePriority: number, + NoPriority: number, +} + +type ReactTypeOfSideEffectType = { + NoFlags: number, + PerformedWork: number, + Placement: number, +} + +local function getFiberFlags(fiber: Fiber): number + -- The name of this field changed from "effectTag" to "flags" + if fiber.flags ~= nil then + return fiber.flags + else + return (fiber :: any).effectTag + end +end + +local getCurrentTime = function() + -- ROBLOX deviation: use os.clock not performance + return os.clock() +end + +exports.getInternalReactConstants = function(version: string): { + getDisplayNameForFiber: getDisplayNameForFiberType, + getTypeSymbol: getTypeSymbolType, + ReactPriorityLevels: ReactPriorityLevelsType, + ReactTypeOfSideEffect: ReactTypeOfSideEffectType, + ReactTypeOfWork: WorkTagMap, +} + local ReactTypeOfSideEffect = { + NoFlags = 0, + PerformedWork = 1, + Placement = 2, + } + + -- ********************************************************** + -- The section below is copied from files in React repo. + -- Keep it in sync, and add version guards if it changes. + -- + -- Technically these priority levels are invalid for versions before 16.9, + -- but 16.9 is the first version to report priority level to DevTools, + -- so we can avoid checking for earlier versions and support pre-16.9 canary releases in the process. + local ReactPriorityLevels = { + ImmediatePriority = 99, + UserBlockingPriority = 98, + NormalPriority = 97, + LowPriority = 96, + IdlePriority = 95, + NoPriority = 90, + } + + -- ROBLOX deviation: we don't need to support older versions + -- if gte(version, '17.0.0-alpha') then + local ReactTypeOfWork: WorkTagMap = { + Block = 22, + ClassComponent = 1, + ContextConsumer = 9, + ContextProvider = 10, + CoroutineComponent = -1, + CoroutineHandlerPhase = -1, + DehydratedSuspenseComponent = 18, + ForwardRef = 11, + Fragment = 7, + FunctionComponent = 0, + HostComponent = 5, + HostPortal = 4, + HostRoot = 3, + HostText = 6, + IncompleteClassComponent = 17, + IndeterminateComponent = 2, + LazyComponent = 16, + MemoComponent = 14, + Mode = 8, + OffscreenComponent = 23, + Profiler = 12, + SimpleMemoComponent = 15, + SuspenseComponent = 13, + SuspenseListComponent = 19, + YieldComponent = -1, + } + -- elseif gte(version, '16.6.0-beta.0') then + -- ReactTypeOfWork = { + -- Block = 22, + -- ClassComponent = 1, + -- ContextConsumer = 9, + -- ContextProvider = 10, + -- CoroutineComponent = -1, + -- CoroutineHandlerPhase = -1, + -- DehydratedSuspenseComponent = 18, + -- ForwardRef = 11, + -- Fragment = 7, + -- FunctionComponent = 0, + -- HostComponent = 5, + -- HostPortal = 4, + -- HostRoot = 3, + -- HostText = 6, + -- IncompleteClassComponent = 17, + -- IndeterminateComponent = 2, + -- LazyComponent = 16, + -- MemoComponent = 14, + -- Mode = 8, + -- OffscreenComponent = -1, + -- Profiler = 12, + -- SimpleMemoComponent = 15, + -- SuspenseComponent = 13, + -- SuspenseListComponent = 19, + -- YieldComponent = -1, + -- } + -- elseif gte(version, '16.4.3-alpha') then + -- ReactTypeOfWork = { + -- Block = -1, + -- ClassComponent = 2, + -- ContextConsumer = 11, + -- ContextProvider = 12, + -- CoroutineComponent = -1, + -- CoroutineHandlerPhase = -1, + -- DehydratedSuspenseComponent = -1, + -- ForwardRef = 13, + -- Fragment = 9, + -- FunctionComponent = 0, + -- HostComponent = 7, + -- HostPortal = 6, + -- HostRoot = 5, + -- HostText = 8, + -- IncompleteClassComponent = -1, + -- IndeterminateComponent = 4, + -- LazyComponent = -1, + -- MemoComponent = -1, + -- Mode = 10, + -- OffscreenComponent = -1, + -- Profiler = 15, + -- SimpleMemoComponent = -1, + -- SuspenseComponent = 16, + -- SuspenseListComponent = -1, + -- YieldComponent = -1, + -- } + -- else + -- ReactTypeOfWork = { + -- Block = -1, + -- ClassComponent = 2, + -- ContextConsumer = 12, + -- ContextProvider = 13, + -- CoroutineComponent = 7, + -- CoroutineHandlerPhase = 8, + -- DehydratedSuspenseComponent = -1, + -- ForwardRef = 14, + -- Fragment = 10, + -- FunctionComponent = 1, + -- HostComponent = 5, + -- HostPortal = 4, + -- HostRoot = 3, + -- HostText = 6, + -- IncompleteClassComponent = -1, + -- IndeterminateComponent = 0, + -- LazyComponent = -1, + -- MemoComponent = -1, + -- Mode = 11, + -- OffscreenComponent = -1, + -- Profiler = 15, + -- SimpleMemoComponent = -1, + -- SuspenseComponent = 16, + -- SuspenseListComponent = -1, + -- YieldComponent = 9, + -- } + -- end + + -- // ********************************************************** + -- // End of copied code. + -- // ********************************************************** + + local function getTypeSymbol(type_: any): Symbol | number + local symbolOrNumber = if typeof(type_) == "table" then type_["$$typeof"] else type_ + + -- ROBLOX deviation: symbol is not a native Luau type + return if typeof(symbolOrNumber) == "table" then tostring(symbolOrNumber) else symbolOrNumber + end + + local ClassComponent, IncompleteClassComponent, FunctionComponent, IndeterminateComponent, ForwardRef, HostRoot, HostComponent, HostPortal, HostText, Fragment, MemoComponent, SimpleMemoComponent, SuspenseComponent, SuspenseListComponent = + ReactTypeOfWork.ClassComponent, + ReactTypeOfWork.IncompleteClassComponent, + ReactTypeOfWork.FunctionComponent, + ReactTypeOfWork.IndeterminateComponent, + ReactTypeOfWork.ForwardRef, + ReactTypeOfWork.HostRoot, + ReactTypeOfWork.HostComponent, + ReactTypeOfWork.HostPortal, + ReactTypeOfWork.HostText, + ReactTypeOfWork.Fragment, + ReactTypeOfWork.MemoComponent, + ReactTypeOfWork.SimpleMemoComponent, + ReactTypeOfWork.SuspenseComponent, + ReactTypeOfWork.SuspenseListComponent + + local function resolveFiberType(type_: any) + local typeSymbol = getTypeSymbol(type_) + if typeSymbol == MEMO_NUMBER or typeSymbol == MEMO_SYMBOL_STRING then + -- recursively resolving memo type in case of memo(forwardRef(Component)) + return resolveFiberType(type_.type) + elseif typeSymbol == FORWARD_REF_NUMBER or typeSymbol == FORWARD_REF_SYMBOL_STRING then + return type_.render + else + return type_ + end + end + + -- NOTICE Keep in sync with shouldFilterFiber() and other get*ForFiber methods + local function getDisplayNameForFiber(fiber: Fiber): string | nil + local type_, tag = fiber.type, fiber.tag + local resolvedType = type_ + + if typeof(type_) == "table" and type_ ~= nil then + resolvedType = resolveFiberType(type_) + end + + local resolvedContext = nil + if tag == ClassComponent or tag == IncompleteClassComponent then + return getDisplayName(resolvedType) + elseif tag == FunctionComponent or tag == IndeterminateComponent then + return getDisplayName(resolvedType) + elseif tag == ForwardRef then + -- Mirror https://github.com/facebook/react/blob/7c21bf72ace77094fd1910cc350a548287ef8350/packages/shared/getComponentName.js#L27-L37 + return (type_ and type_.displayName) or getDisplayName(resolvedType, "Anonymous") + elseif tag == HostRoot then + return nil + elseif tag == HostComponent then + return type_ + elseif tag == HostPortal or tag == HostText or tag == Fragment then + return nil + elseif tag == MemoComponent or tag == SimpleMemoComponent then + return getDisplayName(resolvedType, "Anonymous") + elseif tag == SuspenseComponent then + return "Suspense" + elseif tag == SuspenseListComponent then + return "SuspenseList" + else + local typeSymbol = getTypeSymbol(type_) + if + typeSymbol == CONCURRENT_MODE_NUMBER + or typeSymbol == CONCURRENT_MODE_SYMBOL_STRING + or typeSymbol == DEPRECATED_ASYNC_MODE_SYMBOL_STRING + then + return nil + elseif typeSymbol == PROVIDER_NUMBER or typeSymbol == PROVIDER_SYMBOL_STRING then + -- 16.3.0 exposed the context object as "context" + -- PR #12501 changed it to "_context" for 16.3.1+ + -- NOTE Keep in sync with inspectElementRaw() + resolvedContext = fiber.type._context or fiber.type.context + return string.format("%s.Provider", resolvedContext.displayName or "Context") + elseif typeSymbol == CONTEXT_NUMBER or typeSymbol == CONTEXT_SYMBOL_STRING then + -- 16.3-16.5 read from "type" because the Consumer is the actual context object. + -- 16.6+ should read from "type._context" because Consumer can be different (in DEV). + -- NOTE Keep in sync with inspectElementRaw() + resolvedContext = fiber.type._context or fiber.type + + -- NOTE: TraceUpdatesBackendManager depends on the name ending in '.Consumer' + -- If you change the name, figure out a more resilient way to detect it. + return string.format("%s.Consumer", resolvedContext.displayName or "Context") + elseif typeSymbol == STRICT_MODE_NUMBER or typeSymbol == STRICT_MODE_SYMBOL_STRING then + return nil + elseif typeSymbol == PROFILER_NUMBER or typeSymbol == PROFILER_SYMBOL_STRING then + return string.format("Profiler(%s)", fiber.memoizedProps.id) + elseif typeSymbol == SCOPE_NUMBER or typeSymbol == SCOPE_SYMBOL_STRING then + return "Scope" + else + -- Unknown element type. + -- This may mean a new element type that has not yet been added to DevTools. + return nil + end + end + end + + return { + getDisplayNameForFiber = getDisplayNameForFiber, + getTypeSymbol = getTypeSymbol, + ReactPriorityLevels = ReactPriorityLevels, + ReactTypeOfWork = ReactTypeOfWork, + ReactTypeOfSideEffect = ReactTypeOfSideEffect, + } +end + +exports.attach = function( + hook: DevToolsHook, + rendererID: number, + renderer: ReactRenderer, + global: Object +): RendererInterface + -- ROBLOX deviation: these definitions have been hoisted to top of function for earlier use + local fiberToIDMap: Map = Map.new() :: Map + local idToFiberMap: Map = Map.new() :: Map + local primaryFibers: Set = Set.new() :: Set + + -- When profiling is supported, we store the latest tree base durations for each Fiber. + -- This is so that we can quickly capture a snapshot of those values if profiling starts. + -- If we didn't store these values, we'd have to crawl the tree when profiling started, + -- and use a slow path to find each of the current Fibers. + local idToTreeBaseDurationMap: Map = Map.new() :: Map + + -- When profiling is supported, we store the latest tree base durations for each Fiber. + -- This map enables us to filter these times by root when sending them to the frontend. + local idToRootMap: Map = Map.new() :: Map + + -- When a mount or update is in progress, this value tracks the root that is being operated on. + local currentRootID: number = -1 + + local function getFiberID(primaryFiber: Fiber) + if not fiberToIDMap:has(primaryFiber) then + local id = getUID() + fiberToIDMap:set(primaryFiber, id) + idToFiberMap:set(id, primaryFiber) + end + + return (fiberToIDMap:get(primaryFiber) :: any) :: number + end + + local _getInternalReactCons = exports.getInternalReactConstants(renderer.version) + local getDisplayNameForFiber, getTypeSymbol, ReactPriorityLevels, ReactTypeOfWork, ReactTypeOfSideEffect = + _getInternalReactCons.getDisplayNameForFiber, + _getInternalReactCons.getTypeSymbol, + _getInternalReactCons.ReactPriorityLevels, + _getInternalReactCons.ReactTypeOfWork, + _getInternalReactCons.ReactTypeOfSideEffect + local PerformedWork = ReactTypeOfSideEffect.PerformedWork + local FunctionComponent, ClassComponent, ContextConsumer, DehydratedSuspenseComponent, Fragment, ForwardRef, HostRoot, HostPortal, HostComponent, HostText, IncompleteClassComponent, IndeterminateComponent, MemoComponent, OffscreenComponent, SimpleMemoComponent, SuspenseComponent, SuspenseListComponent = + ReactTypeOfWork.FunctionComponent, + ReactTypeOfWork.ClassComponent, + ReactTypeOfWork.ContextConsumer, + ReactTypeOfWork.DehydratedSuspenseComponent, + ReactTypeOfWork.Fragment, + ReactTypeOfWork.ForwardRef, + ReactTypeOfWork.HostRoot, + ReactTypeOfWork.HostPortal, + ReactTypeOfWork.HostComponent, + ReactTypeOfWork.HostText, + ReactTypeOfWork.IncompleteClassComponent, + ReactTypeOfWork.IndeterminateComponent, + ReactTypeOfWork.MemoComponent, + ReactTypeOfWork.OffscreenComponent, + ReactTypeOfWork.SimpleMemoComponent, + ReactTypeOfWork.SuspenseComponent, + ReactTypeOfWork.SuspenseListComponent + local ImmediatePriority, UserBlockingPriority, NormalPriority, LowPriority, IdlePriority = + ReactPriorityLevels.ImmediatePriority, + ReactPriorityLevels.UserBlockingPriority, + ReactPriorityLevels.NormalPriority, + ReactPriorityLevels.LowPriority, + ReactPriorityLevels.IdlePriority + + -- ROBLOX deviation: these need binding to self + local overrideHookState = function(...) + return renderer.overrideHookState(...) + end + local overrideHookStateDeletePath = function(...) + return renderer.overrideHookStateDeletePath(...) + end + local overrideHookStateRenamePath = function(...) + return renderer.overrideHookStateRenamePath(...) + end + local overrideProps = function(...) + return renderer.overrideProps(...) + end + local overridePropsDeletePath = function(...) + return renderer.overridePropsDeletePath(...) + end + local overridePropsRenamePath = function(...) + return renderer.overridePropsRenamePath(...) + end + local setSuspenseHandler = function(...) + return renderer.setSuspenseHandler(...) + end + local scheduleUpdate = function(...) + return renderer.scheduleUpdate(...) + end + + local supportsTogglingSuspense = typeof(setSuspenseHandler) == "function" and typeof(scheduleUpdate) == "function" + + -- Patching the console enables DevTools to do a few useful things: + -- * Append component stacks to warnings and error messages + -- * Disable logging during re-renders to inspect hooks (see inspectHooksOfFiber) + -- + -- Don't patch in test environments because we don't want to interfere with Jest's own console overrides. + -- ROBLOX deviation: instead of checking if `process.env.NODE_ENV ~= "production"` + -- we use the __DEV__ global + if _G.__DEV__ then + registerRendererWithConsole(renderer) + + -- The renderer interface can't read these preferences directly, + -- because it is stored in localStorage within the context of the extension. + -- It relies on the extension to pass the preference through via the global. + local appendComponentStack = window.__REACT_DEVTOOLS_APPEND_COMPONENT_STACK__ ~= false + local breakOnConsoleErrors = window.__REACT_DEVTOOLS_BREAK_ON_CONSOLE_ERRORS__ == true + + if appendComponentStack or breakOnConsoleErrors then + patchConsole({ + appendComponentStack = appendComponentStack, + breakOnConsoleErrors = breakOnConsoleErrors, + }) + end + end + + local debug_ = function(name: string, fiber: Fiber, parentFiber: Fiber?): () + if __DEBUG__ then + -- ROBLOX deviation: Use string nil rather than null as it is Roblox convenion + local displayName = getDisplayNameForFiber(fiber) or "nil" + local id = getFiberID(fiber) + local parentDisplayName = if parentFiber ~= nil then getDisplayNameForFiber(parentFiber :: Fiber) else "nil" + local parentID = if parentFiber then getFiberID(parentFiber :: Fiber) else "" + -- NOTE: calling getFiberID or getPrimaryFiber is unsafe here + -- because it will put them in the map. For now, we'll omit them. + -- TODO: better debugging story for this. + -- ROBLOX deviation: avoid incompatible log formatting + console.log( + string.format( + "[renderer] %s %s (%d) %s", + name, + displayName, + id, + if parentFiber + then string.format("%s (%s)", tostring(parentDisplayName), tostring(parentID)) + else "" + ) + ) + end + end + + -- Configurable Components tree filters. + -- ROBLOX deviation: adjusted to use Lua patterns, but we may actually want original RegExp + local hideElementsWithDisplayNames: Set = Set.new() + local hideElementsWithPaths: Set = Set.new() + local hideElementsWithTypes: Set = Set.new() + + -- ROBLOX deviation: local variables need to be defined above their use in closures + -- Roots don't have a real persistent identity. + -- A root's "pseudo key" is "childDisplayName:indexWithThatName". + -- For example, "App:0" or, in case of similar roots, "Story:0", "Story:1", etc. + -- We will use this to try to disambiguate roots when restoring selection between reloads. + local rootPseudoKeys: Map = Map.new() + local rootDisplayNameCounter: Map = Map.new() + + -- ROBLOX deviation: definitions hoisted earlier in function + local currentCommitProfilingMetadata: CommitProfilingData | nil = nil + local displayNamesByRootID: DisplayNamesByRootID | nil = nil + local idToContextsMap: Map | nil = nil + local initialTreeBaseDurationsMap: Map | nil = nil + local initialIDToRootMap: Map | nil = nil + local isProfiling: boolean = false + local profilingStartTime: number = 0 + local recordChangeDescriptions: boolean = false + local rootToCommitProfilingMetadataMap: CommitProfilingMetadataMap | nil = nil + + local mostRecentlyInspectedElement: InspectedElement | nil = nil + local hasElementUpdatedSinceLastInspected: boolean = false + local currentlyInspectedPaths: Object = {} + + local forceFallbackForSuspenseIDs = Set.new() + + -- Highlight updates + local traceUpdatesEnabled: boolean = false + local traceUpdatesForNodes: Set = Set.new() + + -- ROBLOX deviation: hoise local variables + -- Remember if we're trying to restore the selection after reload. + -- In that case, we'll do some extra checks for matching mounts. + local trackedPath: Array | nil = nil + local trackedPathMatchFiber: Fiber | nil = nil + local trackedPathMatchDepth = -1 + local mightBeOnTrackedPath = false + + -- ROBLOX deviation: hoist function variables + local getChangedKeys: (prev: any, next_: any) -> nil | Array + local mountFiberRecursively: ( + fiber: Fiber, + parentFiber: Fiber | nil, + traverseSiblings: boolean, + traceNearestHostComponentUpdate: boolean + ) -> () + local findAllCurrentHostFibers: (id: number) -> Array + local setTrackedPath: (path: Array | nil) -> () + local getPrimaryFiber, unmountFiberChildrenRecursively, recordUnmount, setRootPseudoKey, removeRootPseudoKey, flushPendingEvents, getElementTypeForFiber, getContextChangedKeys, didHooksChange, getContextsForFiber, getDisplayNameForRoot, recordProfilingDurations, updateTrackedPathStateBeforeMount, updateTrackedPathStateAfterMount, findReorderedChildrenRecursively, findCurrentFiberUsingSlowPathById, isMostRecentlyInspectedElementCurrent, getPathFrame + + local function applyComponentFilters(componentFilters: Array) + hideElementsWithTypes:clear() + hideElementsWithDisplayNames:clear() + hideElementsWithPaths:clear() + -- ROBLOX TODO: translate to Array.forEach + for _, componentFilter in componentFilters do + if not componentFilter.isEnabled then + continue + end + if componentFilter.type == ComponentFilterDisplayName then + -- ROBLOX deviation: use value directly as pattern rather than creating a RegExp + hideElementsWithDisplayNames:add((componentFilter :: RegExpComponentFilter).value) + elseif componentFilter.type == ComponentFilterElementType then + hideElementsWithTypes:add((componentFilter :: ElementTypeComponentFilter).value) + elseif componentFilter.type == ComponentFilterLocation then + if + (componentFilter :: RegExpComponentFilter).isValid + and (componentFilter :: RegExpComponentFilter).value ~= "" + then + -- ROBLOX deviation: use value directly as pattern rather than creating a RegExp + hideElementsWithPaths:add((componentFilter :: RegExpComponentFilter).value) + end + elseif componentFilter.type == ComponentFilterHOC then + hideElementsWithDisplayNames:add("%(") + else + console.warn(string.format('Invalid component filter type "%d"', componentFilter.type)) + end + end + end + + -- The renderer interface can't read saved component filters directly, + -- because they are stored in localStorage within the context of the extension. + -- Instead it relies on the extension to pass filters through. + if window.__REACT_DEVTOOLS_COMPONENT_FILTERS__ ~= nil then + applyComponentFilters(window.__REACT_DEVTOOLS_COMPONENT_FILTERS__) + else + -- Unfortunately this feature is not expected to work for React Native for now. + -- It would be annoying for us to spam YellowBox warnings with unactionable stuff, + -- so for now just skip this message... + --console.warn('⚛️ DevTools: Could not locate saved component filters'); + + -- Fallback to assuming the default filters in this case. + applyComponentFilters(getDefaultComponentFilters()) + end + + -- If necessary, we can revisit optimizing this operation. + -- For example, we could add a new recursive unmount tree operation. + -- The unmount operations are already significantly smaller than mount operations though. + -- This is something to keep in mind for later. + local function updateComponentFilters(componentFilters: Array) + if isProfiling then + -- Re-mounting a tree while profiling is in progress might break a lot of assumptions. + -- If necessary, we could support this- but it doesn't seem like a necessary use case. + error("Cannot modify filter preferences while profiling") + end + + hook.getFiberRoots(rendererID):forEach(function(root) + currentRootID = getFiberID(getPrimaryFiber(root.current)) + unmountFiberChildrenRecursively(root.current) + recordUnmount(root.current, false) + currentRootID = -1 + end) + + applyComponentFilters(componentFilters) + + -- Reset pseudo counters so that new path selections will be persisted. + rootDisplayNameCounter:clear() + + -- Recursively re-mount all roots with new filter criteria applied. + hook.getFiberRoots(rendererID):forEach(function(root) + currentRootID = getFiberID(getPrimaryFiber(root.current :: Fiber)) + + setRootPseudoKey(currentRootID, root.current :: Fiber) + mountFiberRecursively(root.current :: Fiber, nil, false, false) + flushPendingEvents(root) + + currentRootID = -1 + end) + end + + -- NOTICE Keep in sync with get*ForFiber methods + local function shouldFilterFiber(fiber: Fiber): boolean + local _debugSource, tag, type_ = fiber._debugSource, fiber.tag, fiber.type + + if tag == DehydratedSuspenseComponent then + -- TODO: ideally we would show dehydrated Suspense immediately. + -- However, it has some special behavior (like disconnecting + -- an alternate and turning into real Suspense) which breaks DevTools. + -- For now, ignore it, and only show it once it gets hydrated. + -- https://github.com/bvaughn/react-devtools-experimental/issues/197 + return true + elseif tag == HostPortal or tag == HostText or tag == Fragment or tag == OffscreenComponent then + return true + elseif tag == HostRoot then + -- It is never valid to filter the root element. + return false + else + local typeSymbol = getTypeSymbol(type_) + if + typeSymbol == CONCURRENT_MODE_NUMBER + or typeSymbol == CONCURRENT_MODE_SYMBOL_STRING + or typeSymbol == DEPRECATED_ASYNC_MODE_SYMBOL_STRING + or typeSymbol == STRICT_MODE_NUMBER + or typeSymbol == STRICT_MODE_SYMBOL_STRING + then + return true + end + end + + local elementType = getElementTypeForFiber(fiber) + + if hideElementsWithTypes:has(elementType) then + return true + end + if hideElementsWithDisplayNames.size > 0 then + local displayName = getDisplayNameForFiber(fiber) + if displayName ~= nil then + -- eslint-disable-next-line no-for-of-loops/no-for-of-loops + for _, displayNameRegExp in hideElementsWithDisplayNames do + -- ROBLOX deviation: these are patterns not RegExps + if string.match(displayName :: string, displayNameRegExp) then + return true + end + end + end + end + if _debugSource ~= nil and hideElementsWithPaths.size > 0 then + local fileName = _debugSource.fileName + + -- eslint-disable-next-line no-for-of-loops/no-for-of-loops + for _, pathRegExp in hideElementsWithPaths do + -- ROBLOX deviation: these are patterns not RegExps + if string.match(fileName, pathRegExp) then + return true + end + end + end + + return false + end + + -- NOTICE Keep in sync with shouldFilterFiber() and other get*ForFiber methods + getElementTypeForFiber = function(fiber: Fiber): ElementType + local type_, tag = fiber.type, fiber.tag + + if tag == ClassComponent or tag == IncompleteClassComponent then + return ElementTypeClass + elseif tag == FunctionComponent or tag == IndeterminateComponent then + return ElementTypeFunction + elseif tag == ForwardRef then + return ElementTypeForwardRef + elseif tag == HostRoot then + return ElementTypeRoot + elseif tag == HostComponent then + return ElementTypeHostComponent + elseif tag == HostPortal or tag == HostText or tag == Fragment then + return ElementTypeOtherOrUnknown + elseif tag == MemoComponent or tag == SimpleMemoComponent then + return ElementTypeMemo + elseif tag == SuspenseComponent then + return ElementTypeSuspense + elseif tag == SuspenseListComponent then + return ElementTypeSuspenseList + else + local typeSymbol = getTypeSymbol(type_) + if + typeSymbol == CONCURRENT_MODE_NUMBER + or typeSymbol == CONCURRENT_MODE_SYMBOL_STRING + or typeSymbol == DEPRECATED_ASYNC_MODE_SYMBOL_STRING + then + return ElementTypeContext + elseif typeSymbol == PROVIDER_NUMBER or typeSymbol == PROVIDER_SYMBOL_STRING then + return ElementTypeContext + elseif typeSymbol == CONTEXT_NUMBER or typeSymbol == CONTEXT_SYMBOL_STRING then + return ElementTypeContext + elseif typeSymbol == STRICT_MODE_NUMBER or typeSymbol == STRICT_MODE_SYMBOL_STRING then + return ElementTypeOtherOrUnknown + elseif typeSymbol == PROFILER_NUMBER or typeSymbol == PROFILER_SYMBOL_STRING then + return ElementTypeProfiler + else + return ElementTypeOtherOrUnknown + end + end + end + + -- This is a slightly annoying indirection. + -- It is currently necessary because DevTools wants to use unique objects as keys for instances. + -- However fibers have two versions. + -- We use this set to remember first encountered fiber for each conceptual instance. + getPrimaryFiber = function(fiber: Fiber): Fiber + if primaryFibers:has(fiber) then + return fiber + end + + local alternate = fiber.alternate + + if alternate ~= nil and primaryFibers:has(alternate) then + return alternate :: Fiber + end + + primaryFibers:add(fiber) + + return fiber + end + + local function getChangeDescription(prevFiber: Fiber | nil, nextFiber: Fiber): ChangeDescription | nil + local fiberType = getElementTypeForFiber(nextFiber) + if + fiberType == ElementTypeClass + or fiberType == ElementTypeFunction + or fiberType == ElementTypeMemo + or fiberType == ElementTypeForwardRef + -- ROBLOX deviation: Include host components in the report + or fiberType == ElementTypeHostComponent + then + if prevFiber == nil then + return { + context = nil, + didHooksChange = false, + isFirstMount = true, + props = nil, + state = nil, + } + else + return { + context = getContextChangedKeys(nextFiber), + didHooksChange = didHooksChange((prevFiber :: Fiber).memoizedState, nextFiber.memoizedState), + isFirstMount = false, + props = getChangedKeys((prevFiber :: Fiber).memoizedProps, nextFiber.memoizedProps), + state = getChangedKeys((prevFiber :: Fiber).memoizedState, nextFiber.memoizedState), + } + end + else + return nil + end + end + + local function updateContextsForFiber(fiber: Fiber) + if getElementTypeForFiber(fiber) == ElementTypeClass then + if idToContextsMap ~= nil then + local id = getFiberID(getPrimaryFiber(fiber)) + local contexts = getContextsForFiber(fiber) + if contexts ~= nil then + idToContextsMap:set(id, contexts) + end + end + end + end + + -- Differentiates between a null context value and no context. + local NO_CONTEXT = {} + + -- ROBLOX deviation: Luau can't express return type: [Object, any] + getContextsForFiber = function(fiber: Fiber): Array | nil + if getElementTypeForFiber(fiber) == ElementTypeClass then + local instance = fiber.stateNode + local legacyContext = NO_CONTEXT + local modernContext = NO_CONTEXT + if instance ~= nil then + if instance.constructor and instance.constructor.contextType ~= nil then + modernContext = instance.context + else + legacyContext = instance.context + if legacyContext and #Object.keys(legacyContext) == 0 then + legacyContext = NO_CONTEXT + end + end + end + return { legacyContext, modernContext } + end + return nil + end + + -- Record all contexts at the time profiling is started. + -- Fibers only store the current context value, + -- so we need to track them separately in order to determine changed keys. + local function crawlToInitializeContextsMap(fiber: Fiber) + updateContextsForFiber(fiber) + local current = fiber.child + while current ~= nil do + crawlToInitializeContextsMap(current :: Fiber) + current = (current :: Fiber).sibling + end + end + + getContextChangedKeys = function(fiber: Fiber): nil | boolean | Array + if getElementTypeForFiber(fiber) == ElementTypeClass then + if idToContextsMap ~= nil then + local id = getFiberID(getPrimaryFiber(fiber)) + -- ROBLOX TODO? optimize this pattern into just the get + local prevContexts = if idToContextsMap:has(id) then idToContextsMap:get(id) else nil + local nextContexts = getContextsForFiber(fiber) + + if prevContexts == nil or nextContexts == nil then + return nil + end + + local prevLegacyContext, prevModernContext = prevContexts[1], prevContexts[2] + local nextLegacyContext, nextModernContext = + (nextContexts :: Array)[1], (nextContexts :: Array)[2] + + if nextLegacyContext ~= NO_CONTEXT then + return getChangedKeys(prevLegacyContext, nextLegacyContext) + elseif nextModernContext ~= NO_CONTEXT then + return prevModernContext ~= nextModernContext + end + end + end + return nil + end + local function getHighestIndex(array: Array) + local highestIndex = 0 + for k, v in array do + highestIndex = if k > highestIndex then k else highestIndex + end + return highestIndex + end + local function areHookInputsEqual(nextDeps: Array, prevDeps_: Array?) + if prevDeps_ == nil then + return false + end + local prevDeps = prevDeps_ :: Array + + local prevDepLength = getHighestIndex(prevDeps) + local nextDepLength = getHighestIndex(nextDeps) + + if prevDepLength ~= nextDepLength then + return false + end + + for i = 1, prevDepLength do + if not is(nextDeps[i], prevDeps[i]) then + return false + end + end + return true + end + + local function isEffect(memoizedState) + return memoizedState ~= nil + and typeof(memoizedState) == "table" + and memoizedState.tag ~= nil + and memoizedState.create ~= nil + and memoizedState.destroy ~= nil + and memoizedState.deps ~= nil + and (memoizedState.deps == nil or Array.isArray(memoizedState.deps)) + and memoizedState.next + end + + local function didHookChange(prev: any, next: any): boolean + local prevMemoizedState = prev.memoizedState + local nextMemoizedState = next.memoizedState + + if isEffect(prevMemoizedState) and isEffect(nextMemoizedState) then + return prevMemoizedState ~= nextMemoizedState + and not areHookInputsEqual(nextMemoizedState.deps, prevMemoizedState.deps) + end + return nextMemoizedState ~= prevMemoizedState + end + didHooksChange = function(prev: any, next_: any): boolean + if prev == nil or next_ == nil then + return false + end + -- We can't report anything meaningful for hooks changes. + -- ROBLOX deviation: hasOwnProperty doesn't exist + if next_["baseState"] and next_["memoizedState"] and next_["next"] and next_["queue"] then + while next_ ~= nil do + -- ROBLOX deviation START: use didHookChange instead of equality check + if didHookChange(prev, next_) then + -- ROBLOX deviation END + return true + else + next_ = next_.next + prev = prev.next + end + end + end + + return false + end + getChangedKeys = function(prev: any, next_: any): nil | Array + if prev == nil or next_ == nil then + return nil + end + -- We can't report anything meaningful for hooks changes. + -- ROBLOX deviation: hasOwnProperty doesn't exist + if + next_["baseState"] ~= nil + and next_["memoizedState"] ~= nil + and next_["next"] ~= nil + and next_["queue"] ~= nil + then + return nil + end + + local keys = Set.new(Array.concat(Object.keys(prev), Object.keys(next_))) + local changedKeys = {} + -- -- eslint-disable-next-line no-for-of-loops/no-for-of-loops + for _, key in keys do + if prev[key] ~= next_[key] then + table.insert(changedKeys, key) + end + end + + return changedKeys + end + + -- eslint-disable-next-line no-unused-vars + local function didFiberRender(prevFiber: Fiber, nextFiber: Fiber): boolean + local tag = nextFiber.tag + if + tag == ClassComponent + or tag == FunctionComponent + or tag == ContextConsumer + or tag == MemoComponent + or tag == SimpleMemoComponent + then + -- For types that execute user code, we check PerformedWork effect. + -- We don't reflect bailouts (either referential or sCU) in DevTools. + -- eslint-disable-next-line no-bitwise + return bit32.band(getFiberFlags(nextFiber), PerformedWork) == PerformedWork + else + -- Note: ContextConsumer only gets PerformedWork effect in 16.3.3+ + -- so it won't get highlighted with React 16.3.0 to 16.3.2. + -- For host components and other types, we compare inputs + -- to determine whether something is an update. + return prevFiber.memoizedProps ~= nextFiber.memoizedProps + or prevFiber.memoizedState ~= nextFiber.memoizedState + or prevFiber.ref ~= nextFiber.ref + end + end + + local pendingOperations: Array = {} + local pendingRealUnmountedIDs: Array = {} + local pendingSimulatedUnmountedIDs: Array = {} + local pendingOperationsQueue: Array> | nil = {} + local pendingStringTable: Map = Map.new() + local pendingStringTableLength: number = 0 + local pendingUnmountedRootID: number | nil = nil + + local function pushOperation(op: number): () + -- ROBLOX deviation: Use global + if global.__DEV__ then + if not Number.isInteger(op) then + console.error("pushOperation() was called but the value is not an integer.", op) + end + end + table.insert(pendingOperations, op) + end + flushPendingEvents = function(root: Object): () + if + #pendingOperations == 0 + and #pendingRealUnmountedIDs == 0 + and #pendingSimulatedUnmountedIDs == 0 + and pendingUnmountedRootID == nil + then + -- If we aren't profiling, we can just bail out here. + -- No use sending an empty update over the bridge. + -- + -- The Profiler stores metadata for each commit and reconstructs the app tree per commit using: + -- (1) an initial tree snapshot and + -- (2) the operations array for each commit + -- Because of this, it's important that the operations and metadata arrays align, + -- So it's important not to omit even empty operations while profiling is active. + if not isProfiling then + return + end + end + + local numUnmountIDs = #pendingRealUnmountedIDs + + #pendingSimulatedUnmountedIDs + + (if pendingUnmountedRootID == nil then 0 else 1) + local operations: Array = {} + -- ROBLOX deviation: don't create an array of specified length + -- Identify which renderer this update is coming from. + -- 2 -- [rendererID, rootFiberID] + -- -- How big is the string table? + -- + 1 -- [stringTableLength] + -- -- Then goes the actual string table. + -- + pendingStringTableLength + -- -- All unmounts are batched in a single message. + -- -- [TREE_OPERATION_REMOVE, removedIDLength, ...ids] + -- + numUnmountIDs + -- > 0 + -- and (2 + numUnmountIDs) + -- or 0 + -- -- Regular operations + -- + #pendingOperations + + -- Identify which renderer this update is coming from. + -- This enables roots to be mapped to renderers, + -- Which in turn enables fiber props, states, and hooks to be inspected. + local i = 1 + + -- ROBLOX deviation: instead of i++ + local function POSTFIX_INCREMENT() + local prevI = i + i += 1 + return prevI + end + + operations[POSTFIX_INCREMENT()] = rendererID + operations[POSTFIX_INCREMENT()] = currentRootID -- Use this ID in case the root was unmounted! + + -- Now fill in the string table. + -- [stringTableLength, str1Length, ...str1, str2Length, ...str2, ...] + -- ROBLOX deviation: [stringCount, str1, str2, ...] + operations[POSTFIX_INCREMENT()] = pendingStringTableLength + + -- ROBLOX deviation: insert operations in pendingStringTable value-order + local stringTableStartIndex = #operations + + pendingStringTable:forEach(function(value, key) + -- ROBLOX deviation: Don't encode strings + -- operations[POSTFIX_INCREMENT()] = #key + -- local encodedKey = utfEncodeString(key) + -- for j = 1, #encodedKey do + -- operations[i + j] = encodedKey[j] + -- end + -- i = i + #key + operations[stringTableStartIndex + value] = key + + -- ROBLOX deviation: ensure increment is still called + POSTFIX_INCREMENT() + end) + + if numUnmountIDs > 0 then + -- All unmounts except roots are batched in a single message. + operations[POSTFIX_INCREMENT()] = TREE_OPERATION_REMOVE :: number + -- The first number is how many unmounted IDs we're gonna send. + operations[POSTFIX_INCREMENT()] = numUnmountIDs :: number + + -- Fill in the real unmounts in the reverse order. + -- They were inserted parents-first by React, but we want children-first. + -- So we traverse our array backwards. + for j = #pendingRealUnmountedIDs, 1, -1 do + operations[POSTFIX_INCREMENT()] = pendingRealUnmountedIDs[j] :: number + end + + -- Fill in the simulated unmounts (hidden Suspense subtrees) in their order. + -- (We want children to go before parents.) + -- They go *after* the real unmounts because we know for sure they won't be + -- children of already pushed "real" IDs. If they were, we wouldn't be able + -- to discover them during the traversal, as they would have been deleted. + for j = 1, #pendingSimulatedUnmountedIDs do + operations[i + j - 1] = pendingSimulatedUnmountedIDs[j] :: number + end + + i = i + #pendingSimulatedUnmountedIDs + + -- The root ID should always be unmounted last. + if pendingUnmountedRootID ~= nil then + operations[i] = pendingUnmountedRootID :: number + i = i + 1 + end + end + + -- Fill in the rest of the operations. + for j = 1, #pendingOperations do + -- ROBLOX deviation: 1-indexing math + operations[i + j - 1] = pendingOperations[j] :: number + end + + i = i + #pendingOperations + + -- Let the frontend know about tree operations. + -- The first value in this array will identify which root it corresponds to, + -- so we do no longer need to dispatch a separate root-committed event. + if pendingOperationsQueue ~= nil then + -- Until the frontend has been connected, store the tree operations. + -- This will let us avoid walking the tree later when the frontend connects, + -- and it enables the Profiler's reload-and-profile functionality to work as well. + table.insert(pendingOperationsQueue :: Array, operations) + else + -- If we've already connected to the frontend, just pass the operations through. + hook.emit("operations", operations) + end + + -- ROBLOX deviation: replace table instead of truncating it + pendingOperations = {} + pendingRealUnmountedIDs = {} + pendingSimulatedUnmountedIDs = {} + pendingUnmountedRootID = nil + pendingStringTable:clear() + pendingStringTableLength = 0 + end + + local function getStringID(str: string | nil): number + if str == nil or str == "" then + return 0 + end + + -- ROBLOX FIXME Luau: needs type states to not need manual cast + local existingID = pendingStringTable:get(str :: string) + + if existingID ~= nil then + return existingID + end + + local stringID = pendingStringTable.size + 1 + + -- ROBLOX FIXME Luau: needs type states to not need cast + pendingStringTable:set(str :: string, stringID) + -- The string table total length needs to account + -- both for the string length, and for the array item + -- that contains the length itself. Hence + 1. + -- ROBLOX deviation: Don't encode strings, so just count one for the single string entry + -- pendingStringTableLength = pendingStringTableLength + (#str + 1) + pendingStringTableLength += 1 + return stringID + end + + local function recordMount(fiber: Fiber, parentFiber: Fiber | nil) + -- ROBLOX deviation: use global + if global.__DEBUG__ then + debug_("recordMount()", fiber, parentFiber) + end + + local isRoot = fiber.tag == HostRoot + local id = getFiberID(getPrimaryFiber(fiber)) + local hasOwnerMetadata = fiber["_debugOwner"] ~= nil + local isProfilingSupported = fiber["treeBaseDuration"] ~= nil + + if isRoot then + pushOperation(TREE_OPERATION_ADD) + pushOperation(id) + pushOperation(ElementTypeRoot) + pushOperation(if isProfilingSupported then 1 else 0) + pushOperation(if hasOwnerMetadata then 1 else 0) + + if isProfiling then + if displayNamesByRootID ~= nil then + (displayNamesByRootID :: Map):set(id, getDisplayNameForRoot(fiber)) + end + end + else + local key = fiber.key + local displayName = getDisplayNameForFiber(fiber) + local elementType = getElementTypeForFiber(fiber) + local _debugOwner = fiber._debugOwner + local ownerID = if _debugOwner ~= nil then getFiberID(getPrimaryFiber(_debugOwner :: Fiber)) else 0 + local parentID = if Boolean.toJSBoolean(parentFiber) + then getFiberID(getPrimaryFiber(parentFiber :: Fiber)) + else 0 + + local displayNameStringID = getStringID(displayName) + + -- This check is a guard to handle a React element that has been modified + -- in such a way as to bypass the default stringification of the "key" property. + local keyString = if key == nil then nil else tostring(key) + local keyStringID = getStringID(keyString) + + pushOperation(TREE_OPERATION_ADD) + pushOperation(id) + pushOperation(elementType) + pushOperation(parentID) + pushOperation(ownerID) + pushOperation(displayNameStringID) + pushOperation(keyStringID) + end + if isProfilingSupported then + idToRootMap:set(id, currentRootID) + recordProfilingDurations(fiber) + end + end + recordUnmount = function(fiber: Fiber, isSimulated: boolean) + -- ROBLOX deviation: use global + if global.__DEBUG__ then + debug_("recordUnmount()", fiber) + end + + if trackedPathMatchFiber ~= nil then + -- We're in the process of trying to restore previous selection. + -- If this fiber matched but is being unmounted, there's no use trying. + -- Reset the state so we don't keep holding onto it. + if fiber == trackedPathMatchFiber or fiber == (trackedPathMatchFiber :: Fiber).alternate then + setTrackedPath(nil) + end + end + + local isRoot = fiber.tag == HostRoot + local primaryFiber = getPrimaryFiber(fiber) + if not fiberToIDMap:has(primaryFiber) then + -- If we've never seen this Fiber, it might be because + -- it is inside a non-current Suspense fragment tree, + -- and so the store is not even aware of it. + -- In that case we can just ignore it, or otherwise + -- there will be errors later on. + primaryFibers:delete(primaryFiber) + -- TODO: this is fragile and can obscure actual bugs. + return + end + + local id = getFiberID(primaryFiber) + + if isRoot then + -- Roots must be removed only after all children (pending and simulated) have been removed. + -- So we track it separately. + pendingUnmountedRootID = id + elseif not shouldFilterFiber(fiber) then + -- To maintain child-first ordering, + -- we'll push it into one of these queues, + -- and later arrange them in the correct order. + if isSimulated then + table.insert(pendingSimulatedUnmountedIDs, id) + else + table.insert(pendingRealUnmountedIDs, id) + end + end + + fiberToIDMap:delete(primaryFiber) + idToFiberMap:delete(id) + primaryFibers:delete(primaryFiber) + + -- ROBLOX deviation: hasOwnProperty doesn't exist + local isProfilingSupported = fiber["treeBaseDuration"] ~= nil + + if isProfilingSupported then + idToRootMap:delete(id) + idToTreeBaseDurationMap:delete(id) + end + end + mountFiberRecursively = function( + fiber: Fiber, + parentFiber: Fiber | nil, + traverseSiblings: boolean, + traceNearestHostComponentUpdate: boolean + ): () + if __DEBUG__ then + debug_("mountFiberRecursively()", fiber, parentFiber) + end + + -- If we have the tree selection from previous reload, try to match this Fiber. + -- Also remember whether to do the same for siblings. + local mightSiblingsBeOnTrackedPath = updateTrackedPathStateBeforeMount(fiber) + local shouldIncludeInTree = not shouldFilterFiber(fiber) + + if shouldIncludeInTree then + recordMount(fiber, parentFiber) + end + if traceUpdatesEnabled then + if traceNearestHostComponentUpdate then + local elementType = getElementTypeForFiber(fiber) + -- If an ancestor updated, we should mark the nearest host nodes for highlighting. + if elementType == ElementTypeHostComponent then + traceUpdatesForNodes:add(fiber.stateNode) + + traceNearestHostComponentUpdate = false + end + end + + -- We intentionally do not re-enable the traceNearestHostComponentUpdate flag in this branch, + -- because we don't want to highlight every host node inside of a newly mounted subtree. + end + + local isSuspense = fiber.tag == ReactTypeOfWork.SuspenseComponent + + if isSuspense then + local isTimedOut = fiber.memoizedState ~= nil + + if isTimedOut then + -- Special case: if Suspense mounts in a timed-out state, + -- get the fallback child from the inner fragment and mount + -- it as if it was our own child. Updates handle this too. + local primaryChildFragment = fiber.child + local fallbackChildFragment = if primaryChildFragment then primaryChildFragment.sibling else nil + local fallbackChild = if fallbackChildFragment then fallbackChildFragment.child else nil + + if fallbackChild ~= nil then + mountFiberRecursively( + fallbackChild, + if shouldIncludeInTree then fiber else parentFiber, + true, + traceNearestHostComponentUpdate + ) + end + else + local primaryChild = nil + local areSuspenseChildrenConditionallyWrapped = OffscreenComponent == -1 + + if areSuspenseChildrenConditionallyWrapped then + primaryChild = fiber.child + elseif fiber.child ~= nil then + primaryChild = (fiber.child :: Fiber).child + end + if primaryChild ~= nil then + mountFiberRecursively( + primaryChild, + if shouldIncludeInTree then fiber else parentFiber, + true, + traceNearestHostComponentUpdate + ) + end + end + else + if fiber.child ~= nil then + mountFiberRecursively( + fiber.child, + if shouldIncludeInTree then fiber else parentFiber, + true, + traceNearestHostComponentUpdate + ) + end + end + + -- We're exiting this Fiber now, and entering its siblings. + -- If we have selection to restore, we might need to re-activate tracking. + updateTrackedPathStateAfterMount(mightSiblingsBeOnTrackedPath) + + if traverseSiblings and fiber.sibling ~= nil then + mountFiberRecursively(fiber.sibling, parentFiber :: Fiber, true, traceNearestHostComponentUpdate) + end + end + + -- We use this to simulate unmounting for Suspense trees + -- when we switch from primary to fallback. + unmountFiberChildrenRecursively = function(fiber: Fiber) + -- ROBLOX deviation: use global + if global.__DEBUG__ then + debug_("unmountFiberChildrenRecursively()", fiber) + end + + -- We might meet a nested Suspense on our way. + local isTimedOutSuspense = fiber.tag == ReactTypeOfWork.SuspenseComponent and fiber.memoizedState ~= nil + local child = fiber.child + + if isTimedOutSuspense then + -- If it's showing fallback tree, let's traverse it instead. + local primaryChildFragment = fiber.child + local fallbackChildFragment = if primaryChildFragment then primaryChildFragment.sibling else nil + + -- Skip over to the real Fiber child. + child = if fallbackChildFragment then fallbackChildFragment.child else nil + end + + while child ~= nil do + -- Record simulated unmounts children-first. + -- We skip nodes without return because those are real unmounts. + if (child :: Fiber).return_ ~= nil then + unmountFiberChildrenRecursively(child :: Fiber) + recordUnmount(child :: Fiber, true) + end + + child = (child :: Fiber).sibling + end + end + recordProfilingDurations = function(fiber: Fiber) + local id = getFiberID(getPrimaryFiber(fiber)) + local actualDuration, treeBaseDuration = fiber.actualDuration, fiber.treeBaseDuration + + idToTreeBaseDurationMap:set(id, treeBaseDuration or 0) + + if isProfiling then + local alternate = fiber.alternate + + -- It's important to update treeBaseDuration even if the current Fiber did not render, + -- because it's possible that one of its descendants did. + if alternate == nil or treeBaseDuration ~= (alternate :: Fiber).treeBaseDuration then + local convertedTreeBaseDuration = math.floor((treeBaseDuration or 0) * 1000) + + pushOperation(TREE_OPERATION_UPDATE_TREE_BASE_DURATION) + pushOperation(id) + pushOperation(convertedTreeBaseDuration) + end + if alternate == nil or didFiberRender(alternate :: Fiber, fiber) then + if actualDuration ~= nil then + -- The actual duration reported by React includes time spent working on children. + -- This is useful information, but it's also useful to be able to exclude child durations. + -- The frontend can't compute this, since the immediate children may have been filtered out. + -- So we need to do this on the backend. + -- Note that this calculated self duration is not the same thing as the base duration. + -- The two are calculated differently (tree duration does not accumulate). + local selfDuration = actualDuration :: number + local child = fiber.child + + while child ~= nil do + selfDuration = selfDuration - ((child :: Fiber).actualDuration or 0) + child = (child :: Fiber).sibling + end + + -- If profiling is active, store durations for elements that were rendered during the commit. + -- Note that we should do this for any fiber we performed work on, regardless of its actualDuration value. + -- In some cases actualDuration might be 0 for fibers we worked on (particularly if we're using Date.now) + -- In other cases (e.g. Memo) actualDuration might be greater than 0 even if we "bailed out". + local metadata = currentCommitProfilingMetadata :: CommitProfilingData + table.insert(metadata.durations, id) + table.insert(metadata.durations, actualDuration :: number) + table.insert(metadata.durations, selfDuration) + metadata.maxActualDuration = math.max(metadata.maxActualDuration, actualDuration :: number) + + if recordChangeDescriptions then + local changeDescription = getChangeDescription(alternate, fiber) + if changeDescription ~= nil then + if metadata.changeDescriptions ~= nil then + (metadata.changeDescriptions :: Map):set( + id, + changeDescription :: ChangeDescription + ) + end + end + + updateContextsForFiber(fiber) + end + end + end + end + end + local function recordResetChildren(fiber: Fiber, childSet: Fiber) + -- The frontend only really cares about the displayName, key, and children. + -- The first two don't really change, so we are only concerned with the order of children here. + -- This is trickier than a simple comparison though, since certain types of fibers are filtered. + local nextChildren: Array = {} + + -- This is a naive implementation that shallowly recourses children. + -- We might want to revisit this if it proves to be too inefficient. + local child: Fiber? = childSet + + while child ~= nil do + findReorderedChildrenRecursively(child :: Fiber, nextChildren) + + child = (child :: Fiber).sibling + end + + local numChildren = #nextChildren + + if numChildren < 2 then + -- No need to reorder. + return + end + + pushOperation(TREE_OPERATION_REORDER_CHILDREN) + pushOperation(getFiberID(getPrimaryFiber(fiber))) + pushOperation(numChildren) + + for i = 1, #nextChildren do + pushOperation(nextChildren[i]) + end + end + + findReorderedChildrenRecursively = function(fiber: Fiber, nextChildren: Array) + if not shouldFilterFiber(fiber) then + table.insert(nextChildren, getFiberID(getPrimaryFiber(fiber))) + else + local child = fiber.child + while child ~= nil do + findReorderedChildrenRecursively(child, nextChildren) + child = (child :: Fiber).sibling + end + end + end + + -- Returns whether closest unfiltered fiber parent needs to reset its child list. + local function updateFiberRecursively( + nextFiber: Fiber, + prevFiber: Fiber, + parentFiber: Fiber | nil, + traceNearestHostComponentUpdate: boolean + ): boolean + -- ROBLOX deviation: use global + if global.__DEBUG__ then + debug_("updateFiberRecursively()", nextFiber, parentFiber) + end + if traceUpdatesEnabled then + local elementType = getElementTypeForFiber(nextFiber) + + if traceNearestHostComponentUpdate then + -- If an ancestor updated, we should mark the nearest host nodes for highlighting. + if elementType == ElementTypeHostComponent then + traceUpdatesForNodes:add(nextFiber.stateNode) + + traceNearestHostComponentUpdate = false + end + else + if + elementType == ElementTypeFunction + or elementType == ElementTypeClass + or elementType == ElementTypeContext + then + -- Otherwise if this is a traced ancestor, flag for the nearest host descendant(s). + traceNearestHostComponentUpdate = didFiberRender(prevFiber, nextFiber) + end + end + end + if + mostRecentlyInspectedElement ~= nil + and (mostRecentlyInspectedElement :: InspectedElement).id == getFiberID(getPrimaryFiber(nextFiber)) + and didFiberRender(prevFiber, nextFiber) + then + -- If this Fiber has updated, clear cached inspected data. + -- If it is inspected again, it may need to be re-run to obtain updated hooks values. + hasElementUpdatedSinceLastInspected = true + end + + local shouldIncludeInTree = not shouldFilterFiber(nextFiber) + local isSuspense = nextFiber.tag == SuspenseComponent + local shouldResetChildren = false + -- The behavior of timed-out Suspense trees is unique. + -- Rather than unmount the timed out content (and possibly lose important state), + -- React re-parents this content within a hidden Fragment while the fallback is showing. + -- This behavior doesn't need to be observable in the DevTools though. + -- It might even result in a bad user experience for e.g. node selection in the Elements panel. + -- The easiest fix is to strip out the intermediate Fragment fibers, + -- so the Elements panel and Profiler don't need to special case them. + -- Suspense components only have a non-null memoizedState if they're timed-out. + local prevDidTimeout = isSuspense and prevFiber.memoizedState ~= nil + local nextDidTimeOut = isSuspense and nextFiber.memoizedState ~= nil + + -- The logic below is inspired by the code paths in updateSuspenseComponent() + -- inside ReactFiberBeginWork in the React source code. + if prevDidTimeout and nextDidTimeOut then + -- Fallback -> Fallback: + -- 1. Reconcile fallback set. + local nextFiberChild = nextFiber.child + local nextFallbackChildSet = if nextFiberChild then nextFiberChild.sibling else nil + -- Note: We can't use nextFiber.child.sibling.alternate + -- because the set is special and alternate may not exist. + local prevFiberChild = prevFiber.child + local prevFallbackChildSet = if prevFiberChild then prevFiberChild.sibling else nil + + if + nextFallbackChildSet ~= nil + and prevFallbackChildSet ~= nil + and updateFiberRecursively( + nextFallbackChildSet :: Fiber, + prevFallbackChildSet :: Fiber, + nextFiber :: Fiber, + traceNearestHostComponentUpdate + ) + then + shouldResetChildren = true + end + elseif prevDidTimeout and not nextDidTimeOut then + -- Fallback -> Primary: + -- 1. Unmount fallback set + -- Note: don't emulate fallback unmount because React actually did it. + -- 2. Mount primary set + local nextPrimaryChildSet = nextFiber.child + + if nextPrimaryChildSet ~= nil then + mountFiberRecursively( + nextPrimaryChildSet :: Fiber, + nextFiber :: Fiber, + true, + traceNearestHostComponentUpdate + ) + end + + shouldResetChildren = true + elseif not prevDidTimeout and nextDidTimeOut then + -- Primary -> Fallback: + -- 1. Hide primary set + -- This is not a real unmount, so it won't get reported by React. + -- We need to manually walk the previous tree and record unmounts. + unmountFiberChildrenRecursively(prevFiber) + + -- 2. Mount fallback set + local nextFiberChild = nextFiber.child + local nextFallbackChildSet = if nextFiberChild then nextFiberChild.sibling else nil + + if nextFallbackChildSet ~= nil then + mountFiberRecursively(nextFallbackChildSet, nextFiber, true, traceNearestHostComponentUpdate) + + shouldResetChildren = true + end + else + -- Common case: Primary -> Primary. + -- This is the same code path as for non-Suspense fibers. + if nextFiber.child ~= prevFiber.child then + -- If the first child is different, we need to traverse them. + -- Each next child will be either a new child (mount) or an alternate (update). + local nextChild: Fiber? = nextFiber.child + local prevChildAtSameIndex = prevFiber.child + + while nextChild do + -- We already know children will be referentially different because + -- they are either new mounts or alternates of previous children. + -- Schedule updates and mounts depending on whether alternates exist. + -- We don't track deletions here because they are reported separately. + if (nextChild :: Fiber).alternate then + local prevChild = (nextChild :: Fiber).alternate + + if + updateFiberRecursively( + nextChild :: Fiber, + prevChild :: Fiber, + if shouldIncludeInTree then nextFiber else parentFiber :: Fiber, + traceNearestHostComponentUpdate + ) + then + -- If a nested tree child order changed but it can't handle its own + -- child order invalidation (e.g. because it's filtered out like host nodes), + -- propagate the need to reset child order upwards to this Fiber. + shouldResetChildren = true + end + -- However we also keep track if the order of the children matches + -- the previous order. They are always different referentially, but + -- if the instances line up conceptually we'll want to know that. + if prevChild ~= prevChildAtSameIndex then + shouldResetChildren = true + end + else + mountFiberRecursively( + nextChild :: Fiber, + if shouldIncludeInTree then nextFiber else parentFiber, + false, + traceNearestHostComponentUpdate + ) + + shouldResetChildren = true + end + + -- Try the next child. + nextChild = nextChild.sibling :: Fiber + + -- Advance the pointer in the previous list so that we can + -- keep comparing if they line up. + if not shouldResetChildren and prevChildAtSameIndex ~= nil then + prevChildAtSameIndex = (prevChildAtSameIndex :: Fiber).sibling + end + end + + -- If we have no more children, but used to, they don't line up. + if prevChildAtSameIndex ~= nil then + shouldResetChildren = true + end + else + if traceUpdatesEnabled then + -- If we're tracing updates and we've bailed out before reaching a host node, + -- we should fall back to recursively marking the nearest host descendants for highlight. + if traceNearestHostComponentUpdate then + local hostFibers = findAllCurrentHostFibers(getFiberID(getPrimaryFiber(nextFiber))) + + for _, hostFiber in hostFibers do + traceUpdatesForNodes:add(hostFiber.stateNode) + end + end + end + end + end + if shouldIncludeInTree then + -- ROBLOX deviation: hasOwnProperty doesn't exist + local isProfilingSupported = nextFiber["treeBaseDuration"] ~= nil + + if isProfilingSupported then + recordProfilingDurations(nextFiber) + end + end + if shouldResetChildren then + -- We need to crawl the subtree for closest non-filtered Fibers + -- so that we can display them in a flat children set. + if shouldIncludeInTree then + -- Normally, search for children from the rendered child. + local nextChildSet = nextFiber.child + + if nextDidTimeOut then + -- Special case: timed-out Suspense renders the fallback set. + local nextFiberChild = nextFiber.child + + nextChildSet = if nextFiberChild then nextFiberChild.sibling else nil + end + if nextChildSet ~= nil then + recordResetChildren(nextFiber, nextChildSet :: Fiber) + end + + -- We've handled the child order change for this Fiber. + -- Since it's included, there's no need to invalidate parent child order. + return false + else + -- Let the closest unfiltered parent Fiber reset its child order instead. + return true + end + else + return false + end + end + local function cleanup() + -- We don't patch any methods so there is no cleanup. + end + + local function flushInitialOperations() + local localPendingOperationsQueue = pendingOperationsQueue + + pendingOperationsQueue = nil + + if localPendingOperationsQueue ~= nil and #(localPendingOperationsQueue :: Array>) > 0 then + for _, operations in localPendingOperationsQueue :: Array> do + hook.emit("operations", operations) + end + else + -- Before the traversals, remember to start tracking + -- our path in case we have selection to restore. + if trackedPath ~= nil then + mightBeOnTrackedPath = true + end + + -- If we have not been profiling, then we can just walk the tree and build up its current state as-is. + hook.getFiberRoots(rendererID):forEach(function(root) + currentRootID = getFiberID(getPrimaryFiber(root.current)) + setRootPseudoKey(currentRootID, root.current) + + -- Checking root.memoizedInteractions handles multi-renderer edge-case- + -- where some v16 renderers support profiling and others don't. + if isProfiling and root.memoizedInteractions ~= nil then + -- If profiling is active, store commit time and duration, and the current interactions. + -- The frontend may request this information after profiling has stopped. + local _tmp = Array.from(root.memoizedInteractions) + + currentCommitProfilingMetadata = { + -- ROBLOX deviation: use bare table instead of Map type + changeDescriptions = if recordChangeDescriptions then Map.new() else nil, + durations = {}, + commitTime = getCurrentTime() - profilingStartTime, + -- ROBLOX TODO: Work out how to deviate this assignment, it's messy + interactions = Array.map( + Array.from(root.memoizedInteractions), + function(interaction: Interaction) + local tmp2 = Object.assign({}, interaction, { + timestamp = interaction.timestamp - profilingStartTime, + }) + return tmp2 + end + ), + maxActualDuration = 0, + priorityLevel = nil, + } + end + + mountFiberRecursively(root.current, nil, false, false) + flushPendingEvents(root) + currentRootID = -1 + end) + end + end + + local function handleCommitFiberUnmount(fiber) + -- This is not recursive. + -- We can't traverse fibers after unmounting so instead + -- we rely on React telling us about each unmount. + recordUnmount(fiber, false) + end + + local formatPriorityLevel = function(priorityLevel: number?) + if priorityLevel == nil then + return "Unknown" + end + if priorityLevel == ImmediatePriority then + return "Immediate" + elseif priorityLevel == UserBlockingPriority then + return "User-Blocking" + elseif priorityLevel == NormalPriority then + return "Normal" + elseif priorityLevel == LowPriority then + return "Low" + elseif priorityLevel == IdlePriority then + return "Idle" + -- ROBLOX deviation: no need to check for NoPriority + else + return "Unknown" + end + end + + local function handleCommitFiberRoot(root: Object, priorityLevel: number?) + local current = root.current + local alternate = current.alternate + + currentRootID = getFiberID(getPrimaryFiber(current)) + + -- Before the traversals, remember to start tracking + -- our path in case we have selection to restore. + if trackedPath ~= nil then + mightBeOnTrackedPath = true + end + if traceUpdatesEnabled then + traceUpdatesForNodes:clear() + end + + -- Checking root.memoizedInteractions handles multi-renderer edge-case- + -- where some v16 renderers support profiling and others don't. + local isProfilingSupported = root.memoizedInteractions ~= nil + + if isProfiling and isProfilingSupported then + local _tmp = Array.from(root.memoizedInteractions) + -- If profiling is active, store commit time and duration, and the current interactions. + -- The frontend may request this information after profiling has stopped. + currentCommitProfilingMetadata = { + -- ROBLOX deviation: use bare table instead of Map type + changeDescriptions = if recordChangeDescriptions then Map.new() else nil, + durations = {}, + commitTime = getCurrentTime() - profilingStartTime, + interactions = Array.map( + Array.from(root.memoizedInteractions), + -- ROBLOX FIXME Luau: shouldn't need this manual annotation + function(interaction: Interaction) + local _tmp2 = Object.assign({}, interaction, { + timestamp = interaction.timestamp - profilingStartTime, + }) + return _tmp2 + end + ), + maxActualDuration = 0, + priorityLevel = if priorityLevel == nil then nil else formatPriorityLevel(priorityLevel), + } + end + if alternate then + -- TODO: relying on this seems a bit fishy. + local wasMounted = (alternate :: Fiber).memoizedState ~= nil + and (alternate :: Fiber).memoizedState.element ~= nil + local isMounted = current.memoizedState ~= nil and current.memoizedState.element ~= nil + + if not wasMounted and isMounted then + -- Mount a new root. + setRootPseudoKey(currentRootID, current) + mountFiberRecursively(current :: Fiber, nil, false, false) + elseif wasMounted and isMounted then + -- Update an existing root. + updateFiberRecursively(current, alternate, nil, false) + elseif wasMounted and not isMounted then + -- Unmount an existing root. + removeRootPseudoKey(currentRootID) + recordUnmount(current, false) + end + else + -- Mount a new root. + setRootPseudoKey(currentRootID, current) + mountFiberRecursively(current :: Fiber, nil, false, false) + end + if isProfiling and isProfilingSupported then + local commitProfilingMetadata = ((rootToCommitProfilingMetadataMap :: any) :: CommitProfilingMetadataMap):get( + currentRootID + ) + + if commitProfilingMetadata ~= nil then + table.insert(commitProfilingMetadata, (currentCommitProfilingMetadata :: any) :: CommitProfilingData) + else + ((rootToCommitProfilingMetadataMap :: any) :: CommitProfilingMetadataMap):set(currentRootID, { + (currentCommitProfilingMetadata :: any) :: CommitProfilingData, + }) + end + end + + -- We're done here. + flushPendingEvents(root) + + if traceUpdatesEnabled then + hook.emit("traceUpdates", traceUpdatesForNodes) + end + + currentRootID = -1 + end + findAllCurrentHostFibers = function(id: number): Array + local fibers = {} + local fiber: Fiber? = findCurrentFiberUsingSlowPathById(id) + + if not fiber then + return fibers + end + + -- Next we'll drill down this component to find all HostComponent/Text. + -- ROBLOX FIXME Luau: shouldn't need cast on the RHS here + local node: Fiber = fiber :: Fiber + + while true do + if node.tag == HostComponent or node.tag == HostText then + table.insert(fibers, node) + elseif node.child then + -- ROBLOX TODO: What do we use instead of "return"? + (node.child :: Fiber).return_ = node + node = node.child :: Fiber + end + if node == fiber then + return fibers + end + + while not node.sibling do + if not node.return_ or node.return_ == fiber then + return fibers + end + + node = node.return_ :: Fiber + end + + (node.sibling :: Fiber).return_ = node.return_ :: Fiber + node = node.sibling :: Fiber + end + + -- Flow needs the return here, but ESLint complains about it. + -- eslint-disable-next-line no-unreachable + return fibers + end + local function findNativeNodesForFiberID(id: number) + -- ROBLOX try + local ok, result = pcall(function() + local fiber: Fiber? = findCurrentFiberUsingSlowPathById(id) + if fiber == nil then + return nil + end + -- Special case for a timed-out Suspense. + local isTimedOutSuspense = (fiber :: Fiber).tag == SuspenseComponent + and (fiber :: Fiber).memoizedState ~= nil + if isTimedOutSuspense then + -- A timed-out Suspense's findDOMNode is useless. + -- Try our best to find the fallback directly. + local maybeFallbackFiber = (fiber :: Fiber).child and ((fiber :: Fiber).child :: Fiber).sibling + if maybeFallbackFiber ~= nil then + fiber = maybeFallbackFiber :: Fiber + end + end + local hostFibers = findAllCurrentHostFibers(id) + -- ROBLOX deviation: filter for Boolean doesn't make sense + return Array.map(hostFibers, function(hostFiber: Fiber) + return hostFiber.stateNode + -- ROBLOX FIXME Luau: remove this any once deferred constraint resolution replaces greedy algorithms + end) :: any + end) + -- ROBLOX catch + if not ok then + -- The fiber might have unmounted by now. + return nil + end + return result + end + + local function getDisplayNameForFiberID(id) + local fiber = idToFiberMap:get(id) + return if fiber ~= nil then getDisplayNameForFiber(fiber) else nil + end + + local function getFiberIDForNative(hostInstance, findNearestUnfilteredAncestor: boolean?): number? + findNearestUnfilteredAncestor = findNearestUnfilteredAncestor or false + local fiber = renderer.findFiberByHostInstance(hostInstance) + + if fiber ~= nil then + if findNearestUnfilteredAncestor then + while fiber ~= nil and shouldFilterFiber(fiber :: Fiber) do + fiber = (fiber :: Fiber).return_ + end + end + return getFiberID(getPrimaryFiber(fiber :: Fiber)) + end + + return nil + end + + -- ROBLOX deviation: The copied code is indeed copied, but from ReactFiberTreeReflection.lua + + -- This function is copied from React and should be kept in sync: + -- https://github.com/facebook/react/blob/master/packages/react-reconciler/src/ReactFiberTreeReflection.js + -- It would be nice if we updated React to inject this function directly (vs just indirectly via findDOMNode). + -- BEGIN copied code + + -- ROBLOX NOTE: Copied these supporting functions from ReactFiberTreeReflection + local function assertIsMounted(fiber) + invariant(getNearestMountedFiber(fiber) == fiber, "Unable to find node on an unmounted component.") + end + + findCurrentFiberUsingSlowPathById = function(id: number): Fiber | nil + local fiber: Fiber? = idToFiberMap:get(id) + + if fiber == nil then + console.warn(string.format('Could not find Fiber with id "%s"', tostring(id))) + return nil + end + + -- ROBLOX NOTE: Copied from ReactFiberTreeReflection.lua + local alternate = (fiber :: Fiber).alternate + if not alternate then + -- If there is no alternate, then we only need to check if it is mounted. + local nearestMounted = getNearestMountedFiber(fiber :: Fiber) + invariant(nearestMounted ~= nil, "Unable to find node on an unmounted component.") + if nearestMounted ~= (fiber :: Fiber) then + return nil + end + return fiber :: Fiber + end + -- If we have two possible branches, we'll walk backwards up to the root + -- to see what path the root points to. On the way we may hit one of the + -- special cases and we'll deal with them. + local a = fiber :: Fiber + local b = alternate :: Fiber + while true do + local parentA = a.return_ + if parentA == nil then + -- We're at the root. + break + end + local parentB = (parentA :: Fiber).alternate + if parentB == nil then + -- There is no alternate. This is an unusual case. Currently, it only + -- happens when a Suspense component is hidden. An extra fragment fiber + -- is inserted in between the Suspense fiber and its children. Skip + -- over this extra fragment fiber and proceed to the next parent. + local nextParent = (parentA :: Fiber).return_ + if nextParent ~= nil then + a = nextParent :: Fiber + b = nextParent :: Fiber + continue + end + -- If there's no parent, we're at the root. + break + end + + -- If both copies of the parent fiber point to the same child, we can + -- assume that the child is current. This happens when we bailout on low + -- priority: the bailed out fiber's child reuses the current child. + if (parentA :: Fiber).child == (parentB :: Fiber).child then + local child = (parentA :: Fiber).child + while child do + if child == a then + -- We've determined that A is the current branch. + assertIsMounted(parentA) + return fiber + end + if child == b then + -- We've determined that B is the current branch. + assertIsMounted(parentA) + return alternate + end + child = child.sibling :: Fiber + end + -- We should never have an alternate for any mounting node. So the only + -- way this could possibly happen is if this was unmounted, if at all. + invariant(false, "Unable to find node on an unmounted component.") + end + + if a.return_ ~= b.return_ then + -- The return pointer of A and the return pointer of B point to different + -- fibers. We assume that return pointers never criss-cross, so A must + -- belong to the child set of A.return_, and B must belong to the child + -- set of B.return_. + a = parentA :: Fiber + b = parentB :: Fiber + else + -- The return pointers point to the same fiber. We'll have to use the + -- default, slow path: scan the child sets of each parent alternate to see + -- which child belongs to which set. + -- + -- Search parent A's child set + local didFindChild = false + local child = (parentA :: Fiber).child + while child do + if child == a then + didFindChild = true + a = parentA :: Fiber + b = parentB :: Fiber + break + end + if child == b then + didFindChild = true + b = parentA :: Fiber + a = parentB :: Fiber + break + end + child = child.sibling :: Fiber + end + if not didFindChild then + -- Search parent B's child set + child = (parentB :: Fiber).child + while child do + if child == a then + didFindChild = true + a = parentB :: Fiber + b = parentA :: Fiber + break + end + if child == b then + didFindChild = true + b = parentB :: Fiber + a = parentA :: Fiber + break + end + child = child.sibling :: Fiber + end + invariant( + didFindChild, + "Child was not found in either parent set. This indicates a bug " + .. "in React related to the return pointer. Please file an issue." + ) + end + end + + invariant( + a.alternate == b, + "Return fibers should always be each others' alternates. " + .. "This error is likely caused by a bug in React. Please file an issue." + ) + end + -- If the root is not a host container, we're in a disconnected tree. I.e. + -- unmounted. + invariant(a.tag == HostRoot, "Unable to find node on an unmounted component.") + if a.stateNode.current == a then + -- We've determined that A is the current branch. + return fiber + end + -- Otherwise B has to be current branch. + return alternate + end + -- END copied code + + local function prepareViewAttributeSource(id: number, path: Array): () + local isCurrent = isMostRecentlyInspectedElementCurrent(id) + + if isCurrent then + window["$attribute"] = getInObject(mostRecentlyInspectedElement :: any, path) + end + end + local function prepareViewElementSource(id: number): () + local fiber: Fiber? = idToFiberMap:get(id) + + if fiber == nil then + console.warn(string.format('Could not find Fiber with id "%s"', tostring(id))) + return + end + + local elementType, tag, type_ = (fiber :: Fiber).elementType, (fiber :: Fiber).tag, (fiber :: Fiber).type + + if + tag == ClassComponent + or tag == FunctionComponent + or tag == IncompleteClassComponent + or tag == IndeterminateComponent + then + global["$type"] = type_ + elseif tag == ForwardRef then + global["$type"] = type_.render + elseif tag == MemoComponent or tag == SimpleMemoComponent then + global["$type"] = elementType ~= nil and elementType.type ~= nil and elementType.type or type_ + else + global["$type"] = nil + end + end + + local function getOwnersList(id: number): Array | nil + local fiber: Fiber? = findCurrentFiberUsingSlowPathById(id) + + if fiber == nil then + return nil + end + + local _debugOwner = (fiber :: Fiber)._debugOwner + local owners = { + { + displayName = getDisplayNameForFiber(fiber :: Fiber) or "Anonymous", + id = id, + type = getElementTypeForFiber(fiber :: Fiber), + }, + } + + if _debugOwner then + local owner: Fiber? = _debugOwner + + while owner ~= nil do + Array.unshift(owners, { + displayName = getDisplayNameForFiber(owner :: Fiber) or "Anonymous", + id = getFiberID(getPrimaryFiber(owner :: Fiber)), + type = getElementTypeForFiber(owner :: Fiber), + }) + + owner = (owner :: Fiber)._debugOwner or nil + end + end + + return owners + end + + -- Fast path props lookup for React Native style editor. + -- Could use inspectElementRaw() but that would require shallow rendering hooks components, + -- and could also mess with memoization. + local function getInstanceAndStyle(id: number): InstanceAndStyle + local instance = nil + local style = nil + local fiber: Fiber? = findCurrentFiberUsingSlowPathById(id) + + if fiber ~= nil then + instance = (fiber :: Fiber).stateNode + + if (fiber :: Fiber).memoizedProps ~= nil then + style = (fiber :: Fiber).memoizedProps.style + end + end + + return { + instance = instance, + style = style, + } + end + + local function inspectElementRaw(id: number): InspectedElement | nil + local fiber: Fiber? = findCurrentFiberUsingSlowPathById(id) + + if fiber == nil then + return nil + end + + local _debugOwner, _debugSource, stateNode, key, memoizedProps, memoizedState, dependencies, tag, type_ = + (fiber :: Fiber)._debugOwner, + (fiber :: Fiber)._debugSource, + (fiber :: Fiber).stateNode, + (fiber :: Fiber).key, + (fiber :: Fiber).memoizedProps, + (fiber :: Fiber).memoizedState, + (fiber :: Fiber).dependencies, + (fiber :: Fiber).tag, + (fiber :: Fiber).type + + local elementType = getElementTypeForFiber(fiber :: Fiber) + + local usesHooks = (tag == FunctionComponent or tag == SimpleMemoComponent or tag == ForwardRef) + and (not not memoizedState or not not dependencies) + + local typeSymbol = getTypeSymbol(type_) + local canViewSource = false + local context = nil + + if + tag == ClassComponent + or tag == FunctionComponent + or tag == IncompleteClassComponent + or tag == IndeterminateComponent + or tag == MemoComponent + or tag == ForwardRef + or tag == SimpleMemoComponent + then + canViewSource = true + + if stateNode and stateNode.context ~= nil then + -- Don't show an empty context object for class components that don't use the context API. + local shouldHideContext = elementType == ElementTypeClass + and not (type_.contextTypes or type_.contextType) + + if not shouldHideContext then + context = stateNode.context + end + end + elseif typeSymbol == CONTEXT_NUMBER or typeSymbol == CONTEXT_SYMBOL_STRING then + -- 16.3-16.5 read from "type" because the Consumer is the actual context object. + -- 16.6+ should read from "type._context" because Consumer can be different (in DEV). + -- NOTE Keep in sync with getDisplayNameForFiber() + local consumerResolvedContext = type_._context or type_ + + -- Global context value. + context = consumerResolvedContext._currentValue or nil + + -- Look for overridden value. + local current = (fiber :: Fiber).return_ + + while current ~= nil do + local currentType = (current :: Fiber).type + local currentTypeSymbol = getTypeSymbol(currentType) + + if currentTypeSymbol == PROVIDER_NUMBER or currentTypeSymbol == PROVIDER_SYMBOL_STRING then + -- 16.3.0 exposed the context object as "context" + -- PR #12501 changed it to "_context" for 16.3.1+ + -- NOTE Keep in sync with getDisplayNameForFiber() + local providerResolvedContext = currentType._context or currentType.context + + if providerResolvedContext == consumerResolvedContext then + context = (current :: Fiber).memoizedProps.value + + break + end + end + + current = (current :: Fiber).return_ + end + end + + local hasLegacyContext = false + + if context ~= nil then + hasLegacyContext = not not type_.contextTypes + -- To simplify hydration and display logic for context, wrap in a value object. + -- Otherwise simple values (e.g. strings, booleans) become harder to handle. + context = { value = context } + end + + local owners: Array? = nil + + if _debugOwner then + owners = {} + local owner: Fiber? = _debugOwner + while owner ~= nil do + table.insert(owners :: Array, { + displayName = getDisplayNameForFiber(owner :: Fiber) or "Anonymous", + id = getFiberID(getPrimaryFiber(owner :: Fiber)), + type = getElementTypeForFiber(owner :: Fiber), + }) + owner = (owner :: Fiber)._debugOwner or nil + end + end + + local isTimedOutSuspense = tag == SuspenseComponent and memoizedState ~= nil + local hooks = nil + + if usesHooks then + local originalConsoleMethods = {} + + -- Temporarily disable all console logging before re-running the hook. + -- ROBLOX TODO: Is iterating over console methods be sensible here? + for method, _ in console do + pcall(function() + originalConsoleMethods[method] = console[method] + console[method] = function() end + end) + end + + pcall(function() + hooks = inspectHooksOfFiber(fiber :: Fiber, renderer.currentDispatcherRef) + end) + + -- Restore original console functionality. + for method, _ in console do + pcall(function() + console[method] = originalConsoleMethods[method] + end) + end + end + + local rootType: string? = nil + local current = fiber :: Fiber + + while current.return_ ~= nil do + current = current.return_ :: Fiber + end + local fiberRoot = current.stateNode + if fiberRoot ~= nil and fiberRoot._debugRootType ~= nil then + rootType = fiberRoot._debugRootType + end + + return { + id = id, + -- Does the current renderer support editable hooks and function props? + canEditHooks = typeof(overrideHookState) == "function", + canEditFunctionProps = typeof(overrideProps) == "function", + -- Does the current renderer support advanced editing interface? + canEditHooksAndDeletePaths = typeof(overrideHookStateDeletePath) == "function", + canEditHooksAndRenamePaths = typeof(overrideHookStateRenamePath) == "function", + canEditFunctionPropsDeletePaths = typeof(overridePropsDeletePath) == "function", + canEditFunctionPropsRenamePaths = typeof(overridePropsRenamePath) == "function", + canToggleSuspense = supportsTogglingSuspense + -- If it's showing the real content, we can always flip fallback. + and ( + not isTimedOutSuspense + -- If it's showing fallback because we previously forced it to, + -- allow toggling it back to remove the fallback override. + or forceFallbackForSuspenseIDs[id] + ), + + -- Can view component source location. + canViewSource = canViewSource, + + -- Does the component have legacy contexted to it. + hasLegacyContext = hasLegacyContext, + -- ROBLOX TODO: upstream has a buggy ternary for this + key = key, + displayName = getDisplayNameForFiber(fiber :: Fiber), + type_ = elementType, + + -- Inspectable properties. + -- TODO Review sanitization approach for the below inspectable values. + context = context, + -- ROBLOX deviation: Luau won't coerce HooksTree to Object + hooks = hooks :: any, + props = memoizedProps, + state = if usesHooks then nil else memoizedState, + + -- List of owners + owners = owners, + + -- Location of component in source code. + source = _debugSource or nil, + + rootType = rootType, + rendererPackageName = renderer.rendererPackageName, + rendererVersion = renderer.version, + } + end + + isMostRecentlyInspectedElementCurrent = function(id: number): boolean + return mostRecentlyInspectedElement ~= nil + and (mostRecentlyInspectedElement :: InspectedElement).id == id + and not hasElementUpdatedSinceLastInspected + end + + -- Track the intersection of currently inspected paths, + -- so that we can send their data along if the element is re-rendered. + local function mergeInspectedPaths(path) + local current = currentlyInspectedPaths + + for _, key in path do + if not Boolean.toJSBoolean(current[key]) then + current[key] = {} + end + current = current[key] + end + end + + local function createIsPathAllowed( + key: string | nil, + secondaryCategory: string | nil -- ROBLOX TODO: Luau can't express literal type: 'hooks' + ) + -- This function helps prevent previously-inspected paths from being dehydrated in updates. + -- This is important to avoid a bad user experience where expanded toggles collapse on update. + return function(path): boolean + if secondaryCategory == "hooks" then + if #path == 1 then + -- Never dehydrate the "hooks" object at the top levels. + return true + end + if path[#path] == "subHooks" or path[#path - 1] == "subHooks" then + -- Dehydrating the 'subHooks' property makes the HooksTree UI a lot more complicated, + -- so it's easiest for now if we just don't break on this boundary. + -- We can always dehydrate a level deeper (in the value object). + return true + end + end + + local current = if key == nil then currentlyInspectedPaths else currentlyInspectedPaths[key] + + if not Boolean.toJSBoolean(current) then + return false + end + + for i = 1, #path do + current = current[path[i]] + if not Boolean.toJSBoolean(current) then + return false + end + end + return true + end + end + + local function updateSelectedElement(inspectedElement: InspectedElement): () + local hooks, id, props = inspectedElement.hooks, inspectedElement.id, inspectedElement.props + local fiber: Fiber? = idToFiberMap:get(id) + + if fiber == nil then + console.warn(string.format('Could not find Fiber with id "%s"', tostring(id))) + + return + end + + local elementType, stateNode, tag, type_ = + (fiber :: Fiber).elementType, (fiber :: Fiber).stateNode, (fiber :: Fiber).tag, (fiber :: Fiber).type + + if tag == ClassComponent or tag == IncompleteClassComponent or tag == IndeterminateComponent then + global["$r"] = stateNode + elseif tag == FunctionComponent then + global["$r"] = { + hooks = hooks, + props = props, + type = type_, + } + elseif tag == ForwardRef then + global["$r"] = { + props = props, + type = type_.render, + } + elseif tag == MemoComponent or tag == SimpleMemoComponent then + global["$r"] = { + props = props, + type = elementType ~= nil and elementType.type ~= nil and elementType.type or type_, + } + else + global["$r"] = nil + end + end + + local function storeAsGlobal(id: number, path: Array, count: number): () + local isCurrent = isMostRecentlyInspectedElementCurrent(id) + + if isCurrent then + local value = getInObject(mostRecentlyInspectedElement :: any, path) + local key = string.format("$reactTemp%s", tostring(count)) + + window[key] = value + + console.log(key) + console.log(value) + end + end + + local function copyElementPath(id: number, path: Array): () + local isCurrent = isMostRecentlyInspectedElementCurrent(id) + + if isCurrent then + copyToClipboard(getInObject(mostRecentlyInspectedElement :: any, path)) + end + end + + local function inspectElement(id: number, path: Array?): InspectedElementPayload + local isCurrent = isMostRecentlyInspectedElementCurrent(id) + + if isCurrent then + if path ~= nil then + mergeInspectedPaths(path :: Array) + + local secondaryCategory = nil + + if (path :: Array)[1] == "hooks" then + secondaryCategory = "hooks" + end + + -- If this element has not been updated since it was last inspected, + -- we can just return the subset of data in the newly-inspected path. + return { + id = id, + type = "hydrated-path", + path = path, + value = cleanForBridge( + getInObject(mostRecentlyInspectedElement :: any, path), + createIsPathAllowed(nil, secondaryCategory), + path + ), + } + else + -- If this element has not been updated since it was last inspected, we don't need to re-run it. + -- Instead we can just return the ID to indicate that it has not changed. + return { + id = id, + type = "no-change", + } + end + else + hasElementUpdatedSinceLastInspected = false + + if mostRecentlyInspectedElement == nil or (mostRecentlyInspectedElement :: InspectedElement).id ~= id then + currentlyInspectedPaths = {} + end + + mostRecentlyInspectedElement = inspectElementRaw(id) + + if mostRecentlyInspectedElement == nil then + return { + id = id, + type = "not-found", + } + end + if path ~= nil then + mergeInspectedPaths(path :: Array) + end + + -- Any time an inspected element has an update, + -- we should update the selected $r value as wel. + -- Do this before dehydration (cleanForBridge). + updateSelectedElement(mostRecentlyInspectedElement :: InspectedElement) + + -- Clone before cleaning so that we preserve the full data. + -- This will enable us to send patches without re-inspecting if hydrated paths are requested. + -- (Reducing how often we shallow-render is a better DX for function components that use hooks.) + local cleanedInspectedElement = Object.assign({}, mostRecentlyInspectedElement) + + cleanedInspectedElement.context = + cleanForBridge(cleanedInspectedElement.context, createIsPathAllowed("context", nil)) + cleanedInspectedElement.hooks = + cleanForBridge(cleanedInspectedElement.hooks, createIsPathAllowed("hooks", "hooks")) + cleanedInspectedElement.props = + cleanForBridge(cleanedInspectedElement.props, createIsPathAllowed("props", nil)) + cleanedInspectedElement.state = + cleanForBridge(cleanedInspectedElement.state, createIsPathAllowed("state", nil)) + + return { + id = id, + type = "full-data", + value = cleanedInspectedElement, + } + end + end + + local function logElementToConsole(id: number) + local result: InspectedElement? = if isMostRecentlyInspectedElementCurrent(id) + then mostRecentlyInspectedElement + else inspectElementRaw(id) + + if result == nil then + console.warn(string.format('Could not find Fiber with id "%s"', tostring(id))) + return + end + + -- ROBLOX TODO: Do we want to support this? Seems out of scope + -- local supportsGroup = typeof(console.groupCollapsed) == 'function' + + -- if supportsGroup then + -- console.groupCollapsed(string.format('[Click to expand] %c<%s />', result.displayName or 'Component'), 'color: var(--dom-tag-name-color); font-weight: normal;') + -- end + if (result :: InspectedElement).props ~= nil then + console.log("Props:", (result :: InspectedElement).props) + end + if (result :: InspectedElement).state ~= nil then + console.log("State:", (result :: InspectedElement).state) + end + if (result :: InspectedElement).hooks ~= nil then + console.log("Hooks:", (result :: InspectedElement).hooks) + end + + local nativeNodes = findNativeNodesForFiberID(id) + + if nativeNodes ~= nil then + console.log("Nodes:", nativeNodes) + end + if (result :: InspectedElement).source ~= nil then + console.log("Location:", (result :: InspectedElement).source) + end + + -- ROBLOX deviation: not needed + -- if (window.chrome || /firefox/i.test(navigator.userAgent)) { + -- console.log( + -- 'Right-click any value to save it as a global variable for further inspection.', + -- ); + -- } + + -- if supportsGroup then + -- console.groupEnd() + -- end + end + + local function deletePath( + type_: string, -- ROBLOX TODO: Luau can't express literal types: 'context' | 'hooks' | 'props' | 'state', + id: number, + hookID: number?, + path: Array + ): () + local fiber: Fiber? = findCurrentFiberUsingSlowPathById(id) + + if fiber ~= nil then + local instance = (fiber :: Fiber).stateNode + + if type_ == "context" then + -- To simplify hydration and display of primitive context values (e.g. number, string) + -- the inspectElement() method wraps context in a {value: ...} object. + -- We need to remove the first part of the path (the "value") before continuing. + path = Array.slice(path, 1) + + if (fiber :: Fiber).tag == ClassComponent then + if #path == 0 then + -- Simple context value (noop) + else + deletePathInObject(instance.context, path) + end + instance:forceUpdate() + elseif (fiber :: Fiber).tag == FunctionComponent then + -- Function components using legacy context are not editable + -- because there's no instance on which to create a cloned, mutated context. + end + elseif type_ == "hooks" then + if type(overrideHookStateDeletePath) == "function" then + overrideHookStateDeletePath(fiber :: Fiber, hookID, path) + end + elseif type_ == "props" then + if instance == nil then + if type(overridePropsDeletePath) == "function" then + overridePropsDeletePath(fiber :: Fiber, path) + end + else + (fiber :: Fiber).pendingProps = copyWithDelete(instance.props, path) + instance:forceUpdate() + end + elseif type_ == "state" then + deletePathInObject(instance.state, path) + instance:forceUpdate() + end + end + end + + local function renamePath( + type_: string, -- ROBLOX deviation: Luau can't express: 'context' | 'hooks' | 'props' | 'state', + id: number, + hookID: number?, + oldPath: Array, + newPath: Array + ): () + local fiber: Fiber? = findCurrentFiberUsingSlowPathById(id) + + if fiber ~= nil then + local instance = (fiber :: Fiber).stateNode + + if type_ == "context" then + -- To simplify hydration and display of primitive context values (e.g. number, string) + -- the inspectElement() method wraps context in a {value: ...} object. + -- We need to remove the first part of the path (the "value") before continuing. + oldPath = Array.slice(oldPath, 1) + newPath = Array.slice(newPath, 1) + + if (fiber :: Fiber).tag == ClassComponent then + if #oldPath == 0 then + -- Simple context value (noop) + else + renamePathInObject(instance.context, oldPath, newPath) + end + instance:forceUpdate() + elseif (fiber :: Fiber).tag == FunctionComponent then + -- Function components using legacy context are not editable + -- because there's no instance on which to create a cloned, mutated context. + end + elseif type_ == "hooks" then + if type(overrideHookStateRenamePath) == "function" then + overrideHookStateRenamePath(fiber, hookID, oldPath, newPath) + end + elseif type_ == "props" then + if instance == nil then + if type(overridePropsRenamePath) == "function" then + overridePropsRenamePath(fiber, oldPath, newPath) + end + else + (fiber :: Fiber).pendingProps = copyWithRename(instance.props, oldPath, newPath) + instance:forceUpdate() + end + elseif type_ == "state" then + renamePathInObject(instance.state, oldPath, newPath) + instance:forceUpdate() + end + end + end + + local function overrideValueAtPath( + type_: string, -- ROBLOX deviation: Luau can't express: 'context' | 'hooks' | 'props' | 'state', + id: number, + hookID: number?, + path: Array, + value: any + ): () + local fiber: Fiber? = findCurrentFiberUsingSlowPathById(id) + + if fiber ~= nil then + local instance = (fiber :: Fiber).stateNode + + if type_ == "context" then + -- To simplify hydration and display of primitive context values (e.g. number, string) + -- the inspectElement() method wraps context in a {value: ...} object. + -- We need to remove the first part of the path (the "value") before continuing. + path = Array.slice(path, 1) + + if (fiber :: Fiber).tag == ClassComponent then + if #path == 0 then + -- Simple context value + instance.context = value + else + setInObject(instance.context, path, value) + end + instance:forceUpdate() + elseif (fiber :: Fiber).tag == FunctionComponent then + -- Function components using legacy context are not editable + -- because there's no instance on which to create a cloned, mutated context. + end + elseif type_ == "hooks" then + if type(overrideHookState) == "function" then + overrideHookState(fiber :: Fiber, hookID, path, value) + end + elseif type_ == "props" then + if instance == nil then + if type(overrideProps) == "function" then + overrideProps(fiber :: Fiber, path, value) + end + else + (fiber :: Fiber).pendingProps = copyWithSet(instance.props, path, value) + instance:forceUpdate() + end + elseif type_ == "state" then + setInObject(instance.state, path, value) + instance:forceUpdate() + end + end + end + + type CommitProfilingData = { + changeDescriptions: Map | nil, + commitTime: number, + durations: Array, + interactions: Array, + maxActualDuration: number, + priorityLevel: string | nil, + } + + type CommitProfilingMetadataMap = Map> + type DisplayNamesByRootID = Map + + local function getProfilingData(): ProfilingDataBackend + local dataForRoots: Array = {} + + if rootToCommitProfilingMetadataMap == nil then + error("getProfilingData() called before any profiling data was recorded") + end + + -- ROBLOX FIXME Luau: need type states to not need this manual cast + (rootToCommitProfilingMetadataMap :: CommitProfilingMetadataMap):forEach( + function(commitProfilingMetadata, rootID) + local commitData: Array = {} + local initialTreeBaseDurations: Array> = {} + local allInteractions: Map = Map.new() + local interactionCommits: Map> = Map.new() + local displayName = displayNamesByRootID ~= nil + and (displayNamesByRootID :: DisplayNamesByRootID):get(rootID) + or "Unknown" + + if initialTreeBaseDurationsMap ~= nil then + initialTreeBaseDurationsMap:forEach(function(treeBaseDuration, id) + if + initialIDToRootMap ~= nil + and (initialIDToRootMap :: Map):get(id) == rootID + then + -- We don't need to convert milliseconds to microseconds in this case, + -- because the profiling summary is JSON serialized. + table.insert(initialTreeBaseDurations, { id, treeBaseDuration }) + end + end) + end + + for commitIndex, commitProfilingData in commitProfilingMetadata do + local changeDescriptions, durations, interactions, maxActualDuration, priorityLevel, commitTime = + commitProfilingData.changeDescriptions, + commitProfilingData.durations, + commitProfilingData.interactions, + commitProfilingData.maxActualDuration, + commitProfilingData.priorityLevel, + commitProfilingData.commitTime + local interactionIDs: Array = {} + + for _, interaction in interactions do + if not allInteractions:has(interaction.id) then + allInteractions:set(interaction.id, interaction) + end + + table.insert(interactionIDs, interaction.id) + + local commitIndices = interactionCommits:get(interaction.id) + + if commitIndices ~= nil then + table.insert(commitIndices, commitIndex) + else + interactionCommits:set(interaction.id, { commitIndex }) + end + end + + local fiberActualDurations: Array> = {} + local fiberSelfDurations: Array> = {} + + for i = 1, #durations, 3 do + local fiberID = durations[i] + table.insert(fiberActualDurations, { fiberID, durations[i + 1] }) + table.insert(fiberSelfDurations, { fiberID, durations[i + 2] }) + end + + table.insert(commitData, { + changeDescriptions = if changeDescriptions ~= nil + -- ROBLOX FIXME: types don't flow from entries through Array.from() return value + then Array.from(changeDescriptions:entries()) :: Array> + else nil, + duration = maxActualDuration, + fiberActualDurations = fiberActualDurations, + fiberSelfDurations = fiberSelfDurations, + interactionIDs = interactionIDs, + priorityLevel = priorityLevel, + timestamp = commitTime, + }) + end + + local _tmpCommits = Array.from(interactionCommits:entries()) + local _tmp = Array.from(allInteractions:entries()) + table.insert(dataForRoots, { + commitData = commitData, + displayName = displayName, + initialTreeBaseDurations = initialTreeBaseDurations, + interactionCommits = Array.from(interactionCommits:entries()), + interactions = Array.from(allInteractions:entries()), + rootID = rootID, + }) + end + ) + + return { + dataForRoots = dataForRoots, + rendererID = rendererID, + } + end + + local function startProfiling(shouldRecordChangeDescriptions: boolean) + if isProfiling then + return + end + + recordChangeDescriptions = shouldRecordChangeDescriptions + + -- Capture initial values as of the time profiling starts. + -- It's important we snapshot both the durations and the id-to-root map, + -- since either of these may change during the profiling session + -- (e.g. when a fiber is re-rendered or when a fiber gets removed). + displayNamesByRootID = Map.new() + initialTreeBaseDurationsMap = Map.new(idToTreeBaseDurationMap) + initialIDToRootMap = Map.new(idToRootMap) + idToContextsMap = Map.new() + + hook.getFiberRoots(rendererID):forEach(function(root) + local rootID = getFiberID(getPrimaryFiber(root.current)); + ((displayNamesByRootID :: any) :: DisplayNamesByRootID):set(rootID, getDisplayNameForRoot(root.current)) + + if shouldRecordChangeDescriptions then + -- Record all contexts at the time profiling is started. + -- Fibers only store the current context value, + -- so we need to track them separately in order to determine changed keys. + crawlToInitializeContextsMap(root.current) + end + end) + + isProfiling = true + profilingStartTime = getCurrentTime() + rootToCommitProfilingMetadataMap = Map.new() + end + + local function stopProfiling() + isProfiling = false + recordChangeDescriptions = false + end + + -- Automatically start profiling so that we don't miss timing info from initial "mount". + if sessionStorageGetItem(SESSION_STORAGE_RELOAD_AND_PROFILE_KEY) == "true" then + startProfiling(sessionStorageGetItem(SESSION_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY) == "true") + end + + -- React will switch between these implementations depending on whether + -- we have any manually suspended Fibers or not. + local function shouldSuspendFiberAlwaysFalse() + return false + end + + local function shouldSuspendFiberAccordingToSet(fiber: Fiber) + local id = getFiberID(getPrimaryFiber(fiber)) + return forceFallbackForSuspenseIDs:has(id) + end + -- ROBLOX FIXME Luau: infers this as (number, a) -> (), but it doesn't later normalize to (number, boolean) -> () + local function overrideSuspense(id: number, forceFallback: boolean): () + if typeof(setSuspenseHandler) ~= "function" or typeof(scheduleUpdate) ~= "function" then + error("Expected overrideSuspense() to not get called for earlier React versions.") + end + if forceFallback then + forceFallbackForSuspenseIDs:add(id) + + if forceFallbackForSuspenseIDs.size == 1 then + -- First override is added. Switch React to slower path. + setSuspenseHandler(shouldSuspendFiberAccordingToSet) + end + else + forceFallbackForSuspenseIDs:delete(id) + + if forceFallbackForSuspenseIDs.size == 0 then + -- Last override is gone. Switch React back to fast path. + setSuspenseHandler(shouldSuspendFiberAlwaysFalse) + end + end + + local fiber: Fiber? = idToFiberMap:get(id) + + if fiber ~= nil then + scheduleUpdate(fiber :: Fiber) + end + end + + setTrackedPath = function(path: Array | nil): () + if path == nil then + trackedPathMatchFiber = nil + trackedPathMatchDepth = -1 + mightBeOnTrackedPath = false + end + + trackedPath = path + end + + -- We call this before traversing a new mount. + -- It remembers whether this Fiber is the next best match for tracked path. + -- The return value signals whether we should keep matching siblings or not. + updateTrackedPathStateBeforeMount = function(fiber: Fiber): boolean + if trackedPath == nil or not mightBeOnTrackedPath then + -- Fast path: there's nothing to track so do nothing and ignore siblings. + return false + end + + local returnFiber = fiber.return_ + local returnAlternate = if returnFiber ~= nil then returnFiber.alternate else nil + -- By now we know there's some selection to restore, and this is a new Fiber. + -- Is this newly mounted Fiber a direct child of the current best match? + -- (This will also be true for new roots if we haven't matched anything yet.) + if + trackedPathMatchFiber == returnFiber + or trackedPathMatchFiber == returnAlternate and returnAlternate ~= nil + then + -- Is this the next Fiber we should select? Let's compare the frames. + local actualFrame = getPathFrame(fiber) + local expectedFrame: PathFrame? = (trackedPath :: Array)[trackedPathMatchDepth + 1] + + if expectedFrame == nil then + error("Expected to see a frame at the next depth.") + end + if + actualFrame.index == (expectedFrame :: PathFrame).index + and actualFrame.key == (expectedFrame :: PathFrame).key + and actualFrame.displayName == (expectedFrame :: PathFrame).displayName + then + -- We have our next match. + trackedPathMatchFiber = fiber + trackedPathMatchDepth = trackedPathMatchDepth + 1 + -- Are we out of frames to match? + if trackedPathMatchDepth == #(trackedPath :: Array) - 1 then + -- There's nothing that can possibly match afterwards. + -- Don't check the children. + mightBeOnTrackedPath = false + else + -- Check the children, as they might reveal the next match. + mightBeOnTrackedPath = true + end + -- In either case, since we have a match, we don't need + -- to check the siblings. They'll never match. + return false + end + end + + -- This Fiber's parent is on the path, but this Fiber itself isn't. + -- There's no need to check its children--they won't be on the path either. + mightBeOnTrackedPath = false + -- However, one of its siblings may be on the path so keep searching. + return true + end + + updateTrackedPathStateAfterMount = function(mightSiblingsBeOnTrackedPath) + -- updateTrackedPathStateBeforeMount() told us whether to match siblings. + -- Now that we're entering siblings, let's use that information. + mightBeOnTrackedPath = mightSiblingsBeOnTrackedPath + end + + -- ROBLOX deviation: rootPseudoKeys and rootDisplayNameCounter defined earlier in the file + setRootPseudoKey = function(id: number, fiber: Fiber) + local name = getDisplayNameForRoot(fiber) + local counter = rootDisplayNameCounter:get(name) or 0 + rootDisplayNameCounter:set(name, counter + 1) + local pseudoKey = string.format("%s:%d", name, counter) + rootPseudoKeys:set(id, pseudoKey) + end + removeRootPseudoKey = function(id: number) + local pseudoKey: string? = rootPseudoKeys:get(id) + + if pseudoKey == nil then + error("Expected root pseudo key to be known.") + end + + -- Luau FIXME: `pseudoKey == nil` above should narrow pseudoKey from string? to string + local name = string.sub(pseudoKey :: string, 1, String.lastIndexOf(pseudoKey :: string, ":") - 1) + local counter = rootDisplayNameCounter:get(name) + + -- ROBLOX FIXME Luau: needs type states to know past this branch count is non-nil + if counter == nil then + error("Expected counter to be known.") + end + if counter :: number > 1 then + rootDisplayNameCounter:set(name, counter :: number - 1) + else + rootDisplayNameCounter:delete(name) + end + + rootPseudoKeys:delete(id) + end + + getDisplayNameForRoot = function(fiber: Fiber): string + local preferredDisplayName = nil + local fallbackDisplayName = nil + local child = fiber.child + -- Go at most three levels deep into direct children + -- while searching for a child that has a displayName. + for i = 0, 3 - 1 do + if child == nil then + break + end + + local displayName = getDisplayNameForFiber(child :: Fiber) + + if displayName ~= nil then + -- Prefer display names that we get from user-defined components. + -- We want to avoid using e.g. 'Suspense' unless we find nothing else. + if typeof((child :: Fiber).type) == "function" then + -- There's a few user-defined tags, but we'll prefer the ones + -- that are usually explicitly named (function or class components). + preferredDisplayName = displayName + elseif fallbackDisplayName == nil then + fallbackDisplayName = displayName + end + end + if preferredDisplayName ~= nil then + break + end + + child = (child :: Fiber).child + end + + return preferredDisplayName or fallbackDisplayName or "Anonymous" + end + + getPathFrame = function(fiber: Fiber): PathFrame + local key = fiber.key + local displayName = getDisplayNameForFiber(fiber) + local index = fiber.index + + if fiber.tag == HostRoot then + -- Roots don't have a real displayName, index, or key. + -- Instead, we'll use the pseudo key (childDisplayName:indexWithThatName). + local id = getFiberID(getPrimaryFiber(fiber)) + local pseudoKey: string? = rootPseudoKeys:get(id) + if pseudoKey == nil then + error("Expected mounted root to have known pseudo key.") + end + displayName = pseudoKey :: string + elseif fiber.tag == HostComponent then + displayName = fiber.type + end + + return { + displayName = displayName, + key = key, + index = index, + } + end + + -- Produces a serializable representation that does a best effort + -- of identifying a particular Fiber between page reloads. + -- The return path will contain Fibers that are "invisible" to the store + -- because their keys and indexes are important to restoring the selection. + local function getPathForElement(id: number): Array | nil + local fiber: Fiber? = idToFiberMap:get(id) + if fiber == nil then + return nil + end + + local keyPath = {} + while fiber ~= nil do + table.insert(keyPath, getPathFrame(fiber :: Fiber)) + fiber = (fiber :: Fiber).return_ + end + + Array.reverse(keyPath) + return keyPath + end + + local function getBestMatchForTrackedPath(): PathMatch | nil + if trackedPath == nil then + -- Nothing to match. + return nil + end + if trackedPathMatchFiber == nil then + -- We didn't find anything. + return nil + end + + -- Find the closest Fiber store is aware of. + local fiber: Fiber? = trackedPathMatchFiber + while fiber ~= nil and shouldFilterFiber(fiber :: Fiber) do + fiber = (fiber :: Fiber).return_ + end + + if fiber == nil then + return nil + end + + return { + id = getFiberID(getPrimaryFiber(fiber :: Fiber)), + isFullMatch = trackedPathMatchDepth == #(trackedPath :: Array), + } + end + + local function setTraceUpdatesEnabled(isEnabled: boolean): () + traceUpdatesEnabled = isEnabled + end + + return { + cleanup = cleanup, + copyElementPath = copyElementPath, + deletePath = deletePath, + findNativeNodesForFiberID = findNativeNodesForFiberID, + flushInitialOperations = flushInitialOperations, + getBestMatchForTrackedPath = getBestMatchForTrackedPath, + getDisplayNameForFiberID = getDisplayNameForFiberID, + getFiberIDForNative = getFiberIDForNative, + getInstanceAndStyle = getInstanceAndStyle, + getOwnersList = getOwnersList, + getPathForElement = getPathForElement, + getProfilingData = getProfilingData, + handleCommitFiberRoot = handleCommitFiberRoot, + handleCommitFiberUnmount = handleCommitFiberUnmount, + inspectElement = inspectElement, + logElementToConsole = logElementToConsole, + prepareViewAttributeSource = prepareViewAttributeSource, + prepareViewElementSource = prepareViewElementSource, + overrideSuspense = overrideSuspense, + overrideValueAtPath = overrideValueAtPath, + renamePath = renamePath, + renderer = renderer, + setTraceUpdatesEnabled = setTraceUpdatesEnabled, + setTrackedPath = setTrackedPath, + startProfiling = startProfiling, + stopProfiling = stopProfiling, + storeAsGlobal = storeAsGlobal, + updateComponentFilters = updateComponentFilters, + -- ROBLOX deviation: expose extra function for Roblox Studio use + getDisplayNameForRoot = getDisplayNameForRoot, + } +end + +return exports diff --git a/packages/react-devtools-shared/src/backend/types.lua b/packages/react-devtools-shared/src/backend/types.lua new file mode 100644 index 00000000..899e6af4 --- /dev/null +++ b/packages/react-devtools-shared/src/backend/types.lua @@ -0,0 +1,373 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/backend/types.js +-- /** +-- * Copyright (c) Facebook, Inc. and its affiliates. +-- * +-- * This source code is licensed under the MIT license found in the +-- * LICENSE file in the root directory of this source tree. +-- * +-- * @flow +-- */ + +local Packages = script.Parent.Parent.Parent +local LuauPolyfill = require(Packages.LuauPolyfill) +type Object = LuauPolyfill.Object +type Array = LuauPolyfill.Array +type Function = (...any) -> any +type Map = LuauPolyfill.Map +type Set = LuauPolyfill.Set +type Symbol = Object +local exports = {} + +-- ROBLOX deviation: rotriever re-exports types to the top-level export +local ReactShared = require(Packages.Shared) +type ReactContext = ReactShared.ReactContext +type Source = ReactShared.Source +local ReactInternalTypes = require(Packages.ReactReconciler) +type Fiber = ReactInternalTypes.Fiber +local Types = require(script.Parent.Parent.types) +type ComponentFilter = Types.ComponentFilter +type ElementType = Types.ElementType + +local DevToolsViewsProfilerTypes = require(script.Parent.Parent.devtools.views.Profiler.types) +type Interaction = DevToolsViewsProfilerTypes.Interaction + +type ResolveNativeStyle = (any) -> Object? + +-- ROBLOX deviation: Luau currently can't express enumerations of literals +-- | 0 -- PROD +-- | 1; -- DEV +type BundleType = number + +export type WorkTag = number +export type WorkFlags = number +export type ExpirationTime = number + +export type WorkTagMap = { + Block: WorkTag, + ClassComponent: WorkTag, + ContextConsumer: WorkTag, + ContextProvider: WorkTag, + CoroutineComponent: WorkTag, + CoroutineHandlerPhase: WorkTag, + DehydratedSuspenseComponent: WorkTag, + ForwardRef: WorkTag, + Fragment: WorkTag, + FunctionComponent: WorkTag, + HostComponent: WorkTag, + HostPortal: WorkTag, + HostRoot: WorkTag, + HostText: WorkTag, + IncompleteClassComponent: WorkTag, + IndeterminateComponent: WorkTag, + LazyComponent: WorkTag, + MemoComponent: WorkTag, + Mode: WorkTag, + OffscreenComponent: WorkTag, + Profiler: WorkTag, + SimpleMemoComponent: WorkTag, + SuspenseComponent: WorkTag, + SuspenseListComponent: WorkTag, + YieldComponent: WorkTag, +} + +-- TODO: If it's useful for the frontend to know which types of data an Element has +-- (e.g. props, state, context, hooks) then we could add a bitmask field for this +-- to keep the number of attributes small. +export type FiberData = { + key: string | nil, + displayName: string | nil, + type: ElementType, +} + +export type NativeType = Object +export type RendererID = number +type Dispatcher = ReactShared.Dispatcher +export type CurrentDispatcherRef = { current: nil | Dispatcher } + +export type GetDisplayNameForFiberID = (number, boolean?) -> string | nil + +export type GetFiberIDForNative = (NativeType, boolean?) -> number | nil +export type FindNativeNodesForFiberID = (number) -> Array? + +export type ReactProviderType = { + -- ROBLOX TODO: Luau can't express field names that require quoted accessor + -- $$typeof: Symbol | number, + [string]: Symbol | number, + _context: ReactContext, + -- ... +} + +-- ROBLOX deviation: most of the instance methods are nil-able upstream, but we can't typecheck inline when using the colon call operator +export type ReactRenderer = { + findFiberByHostInstance: (NativeType) -> Fiber?, + version: string, + rendererPackageName: string, + bundleType: BundleType, + -- 16.9+ + overrideHookState: ((self: ReactRenderer, Object, number, Array, any) -> ()), + -- 17+ + overrideHookStateDeletePath: ((self: ReactRenderer, Object, number, Array) -> ()), + -- 17+ + overrideHookStateRenamePath: (( + self: ReactRenderer, + Object, + number, + Array, + Array + ) -> ()), + -- 16.7+ + overrideProps: ((self: ReactRenderer, Object, Array, any) -> ()), + -- 17+ + overridePropsDeletePath: ((self: ReactRenderer, Object, Array) -> ()), + -- 17+ + overridePropsRenamePath: (( + self: ReactRenderer, + Object, + Array, + Array + ) -> ()), + -- 16.9+ + scheduleUpdate: ((self: ReactRenderer, Object) -> ()), + setSuspenseHandler: (self: ReactRenderer, shouldSuspend: (fiber: Object) -> boolean) -> (), + -- Only injected by React v16.8+ in order to support hooks inspection. + currentDispatcherRef: CurrentDispatcherRef?, + -- Only injected by React v16.9+ in DEV mode. + -- Enables DevTools to append owners-only component stack to error messages. + getCurrentFiber: (() -> Fiber | nil)?, + -- Uniquely identifies React DOM v15. + ComponentTree: any?, + -- Present for React DOM v12 (possibly earlier) through v15. + Mount: any?, + -- ... +} + +export type ChangeDescription = { + context: Array | boolean | nil, + didHooksChange: boolean, + isFirstMount: boolean, + props: Array | nil, + state: Array | nil, +} + +export type CommitDataBackend = { + -- Tuple of fiber ID and change description + -- ROBLOX TODO: how to express bracket syntax embedded in Array type? + -- changeDescriptions: Array<[number, ChangeDescription]> | nil, + changeDescriptions: Array> | nil, + duration: number, + -- Tuple of fiber ID and actual duration + fiberActualDurations: Array>, + -- Tuple of fiber ID and computed "self" duration + fiberSelfDurations: Array>, + interactionIDs: Array, + priorityLevel: string | nil, + timestamp: number, +} + +export type ProfilingDataForRootBackend = { + commitData: Array, + displayName: string, + -- Tuple of Fiber ID and base duration + -- ROBLOX TODO: how to express bracket syntax embedded in Array type? + + initialTreeBaseDurations: Array, + -- Tuple of Interaction ID and commit indices + interactionCommits: Array, + interactions: Array, + rootID: number, +} + +-- Profiling data collected by the renderer interface. +-- This information will be passed to the frontend and combined with info it collects. +export type ProfilingDataBackend = { + dataForRoots: Array, + rendererID: number, +} + +-- ROBLOX deviation: Roact stable keys - slightly widen the type definition of a +-- stable key so that it's likely to work with existing Roact code. Includes +-- numbers for mixed/sparse tables +type RoactStableKey = string | number + +export type PathFrame = { + key: RoactStableKey | nil, + index: number, + displayName: string | nil, +} + +export type PathMatch = { id: number, isFullMatch: boolean } + +export type Owner = { displayName: string | nil, id: number, type: ElementType } + +export type OwnersList = { id: number, owners: Array | nil } + +export type InspectedElement = { + id: number, + + displayName: string | nil, + + -- Does the current renderer support editable hooks and function props? + canEditHooks: boolean, + canEditFunctionProps: boolean, + + -- Does the current renderer support advanced editing interface? + canEditHooksAndDeletePaths: boolean, + canEditHooksAndRenamePaths: boolean, + canEditFunctionPropsDeletePaths: boolean, + canEditFunctionPropsRenamePaths: boolean, + + -- Is this Suspense, and can its value be overridden now? + canToggleSuspense: boolean, + + -- Can view component source location. + canViewSource: boolean, + + -- Does the component have legacy context attached to it. + hasLegacyContext: boolean, + + -- Inspectable properties. + context: Object | nil, + hooks: Object | nil, + props: Object | nil, + state: Object | nil, + key: number | string | nil, + + -- List of owners + owners: Array | nil, + + -- Location of component in source code. + source: Source | nil, + + type_: ElementType, + + -- Meta information about the root this element belongs to. + rootType: string | nil, + + -- Meta information about the renderer that created this element. + rendererPackageName: string | nil, + rendererVersion: string | nil, +} + +exports.InspectElementFullDataType = "full-data" +exports.InspectElementNoChangeType = "no-change" +exports.InspectElementNotFoundType = "not-found" +exports.InspectElementHydratedPathType = "hydrated-path" + +type InspectElementFullData = { + id: number, + -- ROBLOX TODO: Luau can't express literals + -- type: 'full-data', + type: string, + value: InspectedElement, +} + +type InspectElementHydratedPath = { + id: number, + -- ROBLOX TODO: Luau can't express literals + -- type: 'hydrated-path', + type: string, + path: Array, + value: any, +} + +type InspectElementNoChange = { + id: number, + -- ROBLOX TODO: Luau can't express literals + -- type: 'no-change', + type: string, +} + +type InspectElementNotFound = { + id: number, + -- ROBLOX TODO: Luau can't express literals + -- type: 'not-found', + type: string, +} + +export type InspectedElementPayload = + InspectElementFullData + | InspectElementHydratedPath + | InspectElementNoChange + | InspectElementNotFound + +export type InstanceAndStyle = { instance: Object | nil, style: Object | nil } + +-- ROBLOX TODO: Luau can't express literals +-- type Type = 'props' | 'hooks' | 'state' | 'context'; +type Type = string + +export type RendererInterface = { + cleanup: () -> (), + copyElementPath: (number, Array) -> (), + deletePath: (Type, number, number?, Array) -> (), + findNativeNodesForFiberID: FindNativeNodesForFiberID, + flushInitialOperations: () -> (), + getBestMatchForTrackedPath: () -> PathMatch | nil, + getFiberIDForNative: GetFiberIDForNative, + getDisplayNameForFiberID: GetDisplayNameForFiberID, + getInstanceAndStyle: (number) -> InstanceAndStyle, + getProfilingData: () -> ProfilingDataBackend, + getOwnersList: (number) -> Array | nil, + getPathForElement: (number) -> Array | nil, + handleCommitFiberRoot: (Object, number?) -> (), + handleCommitFiberUnmount: (Object) -> (), + inspectElement: (number, Array?) -> InspectedElementPayload, + logElementToConsole: (number) -> (), + overrideSuspense: (number, boolean) -> (), + overrideValueAtPath: (Type, number, number?, Array, any) -> (), + prepareViewAttributeSource: (number, Array) -> (), + prepareViewElementSource: (number) -> (), + renamePath: (Type, number, number?, Array, Array) -> (), + renderer: ReactRenderer | nil, + setTraceUpdatesEnabled: (boolean) -> (), + setTrackedPath: (Array | nil) -> (), + startProfiling: (boolean) -> (), + stopProfiling: () -> (), + storeAsGlobal: (number, Array, number) -> (), + updateComponentFilters: (Array) -> (), + -- ROBLOX TODO: once we are back up to 70% coverage, use [string]: any to approximate the ... below + -- ... + -- ROBLOX deviation: add specific exports needed so the contract is explcit and explicitly typed + getDisplayNameForRoot: (fiber: Fiber) -> string, +} + +export type Handler = (any) -> () + +-- ROBLOX TODO? move these types into shared so reconciler and devtools don't have circlar dep? +export type DevToolsHook = { + listeners: { + [string]: Array, --[[ ...]] + }, + rendererInterfaces: Map, + renderers: Map, + + emit: (string, any) -> (), + getFiberRoots: (RendererID) -> Set, + inject: (ReactRenderer) -> number | nil, + on: (string, Handler) -> (), + off: (string, Handler) -> (), + reactDevtoolsAgent: Object?, + sub: (string, Handler) -> (() -> ()), + + -- Used by react-native-web and Flipper/Inspector + resolveRNStyle: ResolveNativeStyle?, + nativeStyleEditorValidAttributes: Array?, + + -- React uses these methods. + checkDCE: (Function) -> (), + onCommitFiberUnmount: (RendererID, Object) -> (), + onCommitFiberRoot: ( + RendererID, + Object, + -- Added in v16.9 to support Profiler priority labels + number?, + -- Added in v16.9 to support Fast Refresh + boolean? + ) -> (), + -- ROBLOX deviation: track specific additions to interface needed instead of catch-all + supportsFiber: boolean, + isDisabled: boolean?, + -- ... +} + +return exports diff --git a/packages/react-devtools-shared/src/backend/utils.lua b/packages/react-devtools-shared/src/backend/utils.lua new file mode 100644 index 00000000..473b1683 --- /dev/null +++ b/packages/react-devtools-shared/src/backend/utils.lua @@ -0,0 +1,173 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/backend/utils.js +--[[* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. +]] +local Packages = script.Parent.Parent.Parent + +local LuauPolyfill = require(Packages.LuauPolyfill) +local Set = LuauPolyfill.Set +local Array = LuauPolyfill.Array +type Array = LuauPolyfill.Array +type Object = LuauPolyfill.Object + +local hydration = require(script.Parent.Parent.hydration) +local dehydrate = hydration.dehydrate + +local ComponentsTypes = require(script.Parent.Parent.devtools.views.Components.types) +type DehydratedData = ComponentsTypes.DehydratedData + +-- ROBLOX deviation: Use HttpService for JSON +local JSON = game:GetService("HttpService") + +local exports: any = {} + +exports.cleanForBridge = function( + data: Object | nil, + isPathAllowed: (path: Array) -> boolean, + path: Array? +): DehydratedData | nil + path = path or {} + if data ~= nil then + local cleanedPaths: Array> = {} + local unserializablePaths: Array> = {} + local cleanedData = + dehydrate(data :: Object, cleanedPaths, unserializablePaths, path :: Array, isPathAllowed) + return { + data = cleanedData, + cleaned = cleanedPaths, + unserializable = unserializablePaths, + } + else + return nil + end +end +exports.copyToClipboard = function(value: any): () + -- ROBLOX TODO: we will need a different implementation for this + -- local safeToCopy = serializeToString(value) + -- local text = (function() + -- if safeToCopy == nil then + -- return'undefined' + -- end + + -- return safeToCopy + -- end)() + -- local clipboardCopyText = window.__REACT_DEVTOOLS_GLOBAL_HOOK__.clipboardCopyText + + -- if typeof(clipboardCopyText) == 'function' then + -- clipboardCopyText(text).catch(function(err) end) + -- else + -- copy(text) + -- end +end + +exports.copyWithDelete = function( + -- ROBLOX FIXME Luau: workaround for Expected type table, got 'Array | Object' instead + obj: { [any]: any }, --Object | Array, + path: Array, + index: number +): Object | Array + -- ROBLOX deviation: 1-indexed + index = index or 1 + local key = path[index] + -- ROBLOX deviation START: combine [].slice() and spread into single op, because we can + local updated = table.clone(obj) + -- ROBLOX deviation END + + -- ROBLOX deviation: 1-indexed, check for last element + if index == #path then + if Array.isArray(updated) then + Array.splice(updated, key :: number, 1) + else + updated[key] = nil + end + else + updated[key] = exports.copyWithDelete(obj[key], path, index + 1) + end + + return updated +end + +-- This function expects paths to be the same except for the final value. +-- e.g. ['path', 'to', 'foo'] and ['path', 'to', 'bar'] +exports.copyWithRename = function( + -- ROBLOX FIXME Luau: workaround for Expected type table, got 'Array | Object' instead + obj: { [any]: any }, --Object | Array, + oldPath: Array, + newPath: Array, + index: number +): Object | Array + -- ROBLOX deviation: 1-indexed + index = index or 1 + local oldKey = oldPath[index] + -- ROBLOX deviation START: combine [].slice() and spread into single op, because we can + local updated = table.clone(obj) + -- ROBLOX deviation END + + -- ROBLOX deviation: 1-indexed, check for last element + if index == #oldPath then + local newKey = newPath[index] + + updated[newKey] = updated[oldKey] + + if Array.isArray(updated) then + Array.splice(updated, oldKey :: number, 1) + else + updated[oldKey] = nil + end + else + updated[oldKey] = exports.copyWithRename(obj[oldKey], oldPath, newPath, index + 1) + end + + return updated +end + +exports.copyWithSet = function( + -- ROBLOX FIXME Luau: workaround for Expected type table, got 'Array | Object' instead + obj: { [any]: any }, --Object | Array, + path: Array, + value: any, + index: number +): Object | Array + -- ROBLOX deviation: 1-indexed + index = index or 1 + + -- ROBLOX deviation: 1-indexed, check for out of bounds + if index > #path then + return value + end + + local key = path[index] + -- ROBLOX deviation START: combine [].slice() and spread into single op, because we can + local updated = table.clone(obj) + -- ROBLOX deviation END + + updated[key] = exports.copyWithSet(obj[key], path, value, index + 1) + + return updated +end + +exports.serializeToString = function(data: any): string + local cache = Set.new() + + return JSON.JSONEncode(data, function(key, value) + -- ROBLOX deviation: use 'table' not object + if typeof(value) == "table" and value ~= nil then + if cache:has(value) then + return + end + + cache:add(value) + end + -- ROBLOX deviation: not Luau + -- if typeof(value) == 'bigint' then + -- return tostring(value) + 'n' + -- end + + return value + end) +end + +return exports diff --git a/packages/react-devtools-shared/src/bridge.lua b/packages/react-devtools-shared/src/bridge.lua new file mode 100644 index 00000000..ed180ae5 --- /dev/null +++ b/packages/react-devtools-shared/src/bridge.lua @@ -0,0 +1,377 @@ +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/bridge.js +-- /* +-- * Copyright (c) Facebook, Inc. and its affiliates. +-- * +-- * This source code is licensed under the MIT license found in the +-- * LICENSE file in the root directory of this source tree. +-- */ +local Packages = script.Parent.Parent +local LuauPolyfill = require(Packages.LuauPolyfill) +local console = require(Packages.Shared).console +type Array = { [number]: T } +type Function = (...any) -> ...any + +local EventEmitter = require(script.Parent.events) +type EventEmitter = EventEmitter.EventEmitter + +local Types = require(script.Parent.types) +type ComponentFilter = Types.ComponentFilter +type Wall = Types.Wall +local BackendTypes = require(script.Parent.backend.types) +type InspectedElementPayload = BackendTypes.InspectedElementPayload +type OwnersList = BackendTypes.OwnersList +type ProfilingDataBackend = BackendTypes.ProfilingDataBackend +type RendererID = BackendTypes.RendererID + +local BATCH_DURATION = 100 + +type Message = { event: string, payload: any } + +type ElementAndRendererID = { id: number, rendererID: RendererID } + +-- ROBLOX deviation: Luau can't use ...type, use intersection instead +type HighlightElementInDOM = ElementAndRendererID & { + displayName: string?, + hideAfterTimeout: boolean, + openNativeElementsPanel: boolean, + scrollIntoView: boolean, +} + +-- ROBLOX deviation: Luau can't use ...type, use intersection instead +type OverrideValue = ElementAndRendererID & { + path: Array, + wasForwarded: boolean?, + value: any, +} + +-- ROBLOX deviation: Luau can't use ...type, use intersection instead +type OverrideHookState = OverrideValue & { hookID: number } + +-- ROBLOX deviation: 'props' | 'hooks' | 'state' | 'context'; +type PathType = string + +-- ROBLOX deviation: Luau can't use ...type, use intersection instead +type DeletePath = ElementAndRendererID & { type: PathType, hookID: number?, path: Array } + +-- ROBLOX deviation: Luau can't use ...type, use intersection instead +type RenamePath = ElementAndRendererID & { + type: PathType, + hookID: number?, + oldPath: Array, + newPath: Array, +} + +-- ROBLOX deviation: Luau can't use ...type, use intersection instead +type OverrideValueAtPath = ElementAndRendererID & { + type: PathType, + hookID: number?, + path: Array, + value: any, +} + +-- ROBLOX deviation: Luau can't use ...type, use intersection instead +type OverrideSuspense = ElementAndRendererID & { forceFallback: boolean } + +-- ROBLOX deviation: Luau can't use ...type, use intersection instead +type CopyElementPathParams = ElementAndRendererID & { path: Array } + +-- ROBLOX deviation: Luau can't use ...type, use intersection instead +type ViewAttributeSourceParams = ElementAndRendererID & { path: Array } + +-- ROBLOX deviation: Luau can't use ...type, use intersection instead +type InspectElementParams = ElementAndRendererID & { path: Array? } + +-- ROBLOX deviation: Luau can't use ...type, use intersection instead +type StoreAsGlobalParams = ElementAndRendererID & { count: number, path: Array } + +-- ROBLOX deviation: Luau can't use ...type, use intersection instead +type NativeStyleEditor_RenameAttributeParams = ElementAndRendererID & { + oldName: string, + newName: string, + value: string, +} + +-- ROBLOX deviation: Luau can't use ...type, use intersection instead +type NativeStyleEditor_SetValueParams = ElementAndRendererID & { name: string, value: string } + +type UpdateConsolePatchSettingsParams = { + appendComponentStack: boolean, + breakOnConsoleErrors: boolean, +} + +-- ROBLOX deviation: Luau can't define object types in a function type +type IsSupported = { isSupported: boolean, validAttributes: Array } + +type BackendEvents = { + extensionBackendInitialized: () -> (), + inspectedElement: (InspectedElementPayload) -> (), + isBackendStorageAPISupported: (boolean) -> (), + -- ROBLOX deviation: don't binary encode strings + operations: (Array) -> (), + ownersList: (OwnersList) -> (), + overrideComponentFilters: (Array) -> (), + profilingData: (ProfilingDataBackend) -> (), + profilingStatus: (boolean) -> (), + reloadAppForProfiling: () -> (), + selectFiber: (number) -> (), + shutdown: () -> (), + stopInspectingNative: (boolean) -> (), + syncSelectionFromNativeElementsPanel: () -> (), + syncSelectionToNativeElementsPanel: () -> (), + unsupportedRendererVersion: (RendererID) -> (), + + -- React Native style editor plug-in. + isNativeStyleEditorSupported: (IsSupported) -> (), + -- ROBLOX deviation: StyleAndLayoutPayload type not transliterated + NativeStyleEditor_styleAndLayout: () -> (), +} + +type FrontendEvents = { + clearNativeElementHighlight: () -> (), + copyElementPath: (CopyElementPathParams) -> (), + deletePath: (DeletePath) -> (), + getOwnersList: (ElementAndRendererID) -> (), + getProfilingData: ({ rendererID: RendererID }) -> (), + getProfilingStatus: () -> (), + highlightNativeElement: (HighlightElementInDOM) -> (), + inspectElement: (InspectElementParams) -> (), + logElementToConsole: (ElementAndRendererID) -> (), + overrideSuspense: (OverrideSuspense) -> (), + overrideValueAtPath: (OverrideValueAtPath) -> (), + profilingData: (ProfilingDataBackend) -> (), + reloadAndProfile: (boolean) -> (), + renamePath: (RenamePath) -> (), + selectFiber: (number) -> (), + setTraceUpdatesEnabled: (boolean) -> (), + shutdown: () -> (), + startInspectingNative: () -> (), + startProfiling: (boolean) -> (), + stopInspectingNative: (boolean) -> (), + stopProfiling: () -> (), + storeAsGlobal: (StoreAsGlobalParams) -> (), + updateComponentFilters: (Array) -> (), + updateConsolePatchSettings: (UpdateConsolePatchSettingsParams) -> (), + viewAttributeSource: (ViewAttributeSourceParams) -> (), + viewElementSource: (ElementAndRendererID) -> (), + + -- React Native style editor plug-in. + NativeStyleEditor_measure: (ElementAndRendererID) -> (), + NativeStyleEditor_renameAttribute: (NativeStyleEditor_RenameAttributeParams) -> (), + NativeStyleEditor_setValue: (NativeStyleEditor_SetValueParams) -> (), + + -- Temporarily support newer standalone front-ends sending commands to older embedded backends. + -- We do this because React Native embeds the React DevTools backend, + -- but cannot control which version of the frontend users use. + -- + -- Note that nothing in the newer backend actually listens to these events, + -- but the new frontend still dispatches them (in case older backends are listening to them instead). + -- + -- Note that this approach does no support the combination of a newer backend with an older frontend. + -- It would be more work to suppot both approaches (and not run handlers twice) + -- so I chose to support the more likely/common scenario (and the one more difficult for an end user to "fix"). + overrideContext: (OverrideValue) -> (), + overrideHookState: (OverrideHookState) -> (), + overrideProps: (OverrideValue) -> (), + overrideState: (OverrideValue) -> (), +} + +-- ROBLOX deviation: Luau can't spread keys of a type as string +type EventName = string -- $Keys +-- ROBLOX deviation: Luau can't express +-- type $ElementType = T[K]; +type ElementType = any + +export type Bridge< + OutgoingEvents, + IncomingEvents -- ROBLOX deviation: Luau can't express -- > extends EventEmitter<{| -- ...IncomingEvents, -- ...OutgoingEvents, -- |}> { +> = EventEmitter & { + _isShutdown: boolean, + _messageQueue: Array, + _timeoutID: TimeoutID | nil, + _wall: Wall, + _wallUnlisten: Function | nil, + send: ( + self: Bridge, + eventName: EventName, + ...ElementType + ) -> (), + shutdown: (self: Bridge) -> (), + _flush: (self: Bridge) -> (), + overrideValueAtPath: (self: Bridge, _ref: OverrideValueAtPath) -> (), +} + +type Bridge_Statics = { + new: (wall: Wall) -> Bridge, +} + +-- ROBLOX deviation: not sure where TimeoutID comes from in upstream +type TimeoutID = any +local Bridge: Bridge & Bridge_Statics = setmetatable({}, { __index = EventEmitter }) :: any +local BridgeMetatable = { __index = Bridge } + +function Bridge.new(wall: Wall) + local self = setmetatable(EventEmitter.new() :: any, BridgeMetatable) + + -- ROBLOX deviation: initializers from class declaration + self._isShutdown = false + self._messageQueue = {} :: Array> + self._timeoutID = nil + -- _wall + self._wallUnlisten = nil + + self._wall = wall + self._wallUnlisten = wall.listen(function(message: Message) + self:emit(message.event, message.payload) + end) or nil + + -- Temporarily support older standalone front-ends sending commands to newer embedded backends. + -- We do this because React Native embeds the React DevTools backend, + -- but cannot control which version of the frontend users use. + self:addListener("overrideValueAtPath", self.overrideValueAtPath) + + -- ROBLOX deviation: just expose wall as an instance field, instead of read-only property + self.wall = wall + + return self +end + +function Bridge:send(event: EventName, ...: ElementType) + local payload = { ... } + if self._isShutdown then + console.warn(string.format('Cannot send message "%s" through a Bridge that has been shutdown.', event)) + return + end + + -- When we receive a message: + -- - we add it to our queue of messages to be sent + -- - if there hasn't been a message recently, we set a timer for 0 ms in + -- the future, allowing all messages created in the same tick to be sent + -- together + -- - if there *has* been a message flushed in the last BATCH_DURATION ms + -- (or we're waiting for our setTimeout-0 to fire), then _timeoutID will + -- be set, and we'll simply add to the queue and wait for that + table.insert(self._messageQueue, event) + table.insert(self._messageQueue, payload) + + if not self._timeoutID then + self._timeoutID = LuauPolyfill.setTimeout(function() + self:_flush() + end, 0) + end +end + +function Bridge:shutdown() + if self._isShutdown then + console.warn("Bridge was already shutdown.") + return + end + + -- Queue the shutdown outgoing message for subscribers. + self:send("shutdown") + + -- Mark this bridge as destroyed, i.e. disable its public API. + self._isShutdown = true + + -- Disable the API inherited from EventEmitter that can add more listeners and send more messages. + -- $FlowFixMe This property is not writable. + self.addListener = function() end + -- $FlowFixMe This property is not writable. + self.emit = function() end + -- NOTE: There's also EventEmitter API like `on` and `prependListener` that we didn't add to our Flow type of EventEmitter. + + -- Unsubscribe this bridge incoming message listeners to be sure, and so they don't have to do that. + self:removeAllListeners() + + -- Stop accepting and emitting incoming messages from the wall. + local wallUnlisten = self._wallUnlisten + + if wallUnlisten then + wallUnlisten() + end + + -- Synchronously flush all queued outgoing messages. + -- At this step the subscribers' code may run in this call stack. + repeat + self:_flush() + until #self._messageQueue == 0 + + -- Make sure once again that there is no dangling timer. + if self._timeoutID ~= nil then + LuauPolyfill.clearTimeout(self._timeoutID) + + self._timeoutID = nil + end +end + +function Bridge:_flush(): () + -- This method is used after the bridge is marked as destroyed in shutdown sequence, + -- so we do not bail out if the bridge marked as destroyed. + -- It is a private method that the bridge ensures is only called at the right times. + + if self._timeoutID ~= nil then + LuauPolyfill.clearTimeout(self._timeoutID) + + self._timeoutID = nil + end + if #self._messageQueue > 0 then + -- ROBLOX deviation: Use a while loop instead of for loop to handle new insertions during the loop + local i = 1 + while i < #self._messageQueue do + self._wall.send(self._messageQueue[i], table.unpack(self._messageQueue[i + 1])) + i += 2 + end + table.clear(self._messageQueue) + + -- Check again for queued messages in BATCH_DURATION ms. This will keep + -- flushing in a loop as long as messages continue to be added. Once no + -- more are, the timer expires. + self._timeoutID = LuauPolyfill.setTimeout(function() + self:_flush() + end, BATCH_DURATION) + end +end + +-- Temporarily support older standalone backends by forwarding "overrideValueAtPath" commands +-- to the older message types they may be listening to. +function Bridge:overrideValueAtPath(_ref: OverrideValueAtPath) + local id, path, rendererID, type_, value = _ref.id, _ref.path, _ref.rendererID, _ref.type, _ref.value + if type_ == "context" then + self:send("overrideContext", { + id = id, + path = path, + rendererID = rendererID, + wasForwarded = true, + value = value, + }) + elseif type_ == "hooks" then + self:send("overrideHookState", { + id = id, + path = path, + rendererID = rendererID, + wasForwarded = true, + value = value, + }) + elseif type_ == "props" then + self:send("overrideProps", { + id = id, + path = path, + rendererID = rendererID, + wasForwarded = true, + value = value, + }) + elseif type_ == "state" then + self:send("overrideState", { + id = id, + path = path, + rendererID = rendererID, + wasForwarded = true, + value = value, + }) + end +end + +export type BackendBridge = Bridge +export type FrontendBridge = Bridge + +return Bridge diff --git a/packages/react-devtools-shared/src/constants.lua b/packages/react-devtools-shared/src/constants.lua new file mode 100644 index 00000000..983b8772 --- /dev/null +++ b/packages/react-devtools-shared/src/constants.lua @@ -0,0 +1,60 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/constants.js +-- /** +-- * Copyright (c) Facebook, Inc. and its affiliates. +-- * +-- * This source code is licensed under the MIT license found in the +-- * LICENSE file in the root directory of this source tree. +-- * +-- * @flow +-- */ + +local exports = {} + +-- Flip this flag to true to enable verbose console debug logging. +exports.__DEBUG__ = _G.__DEBUG__ + +exports.TREE_OPERATION_ADD = 1 +exports.TREE_OPERATION_REMOVE = 2 +exports.TREE_OPERATION_REORDER_CHILDREN = 3 +exports.TREE_OPERATION_UPDATE_TREE_BASE_DURATION = 4 + +exports.LOCAL_STORAGE_FILTER_PREFERENCES_KEY = "React::DevTools::componentFilters" + +exports.SESSION_STORAGE_LAST_SELECTION_KEY = "React::DevTools::lastSelection" + +exports.SESSION_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY = "React::DevTools::recordChangeDescriptions" + +exports.SESSION_STORAGE_RELOAD_AND_PROFILE_KEY = "React::DevTools::reloadAndProfile" + +exports.LOCAL_STORAGE_SHOULD_BREAK_ON_CONSOLE_ERRORS = "React::DevTools::breakOnConsoleErrors" + +exports.LOCAL_STORAGE_SHOULD_PATCH_CONSOLE_KEY = "React::DevTools::appendComponentStack" + +exports.LOCAL_STORAGE_TRACE_UPDATES_ENABLED_KEY = "React::DevTools::traceUpdatesEnabled" + +exports.PROFILER_EXPORT_VERSION = 4 + +exports.CHANGE_LOG_URL = "https://github.com/facebook/react/blob/master/packages/react-devtools/CHANGELOG.md" + +exports.UNSUPPORTED_VERSION_URL = + "https://reactjs.org/blog/2019/08/15/new-react-devtools.html#how-do-i-get-the-old-version-back" + +-- HACK +-- +-- Extracting during build time avoids a temporarily invalid state for the inline target. +-- Sometimes the inline target is rendered before root styles are applied, +-- which would result in e.g. NaN itemSize being passed to react-window list. +-- +local COMFORTABLE_LINE_HEIGHT +local COMPACT_LINE_HEIGHT + +-- ROBLOX deviation: we won't use the CSS, and don't have a bundler, so always use the 'fallback' +-- We can't use the Webpack loader syntax in the context of Jest, +-- so tests need some reasonably meaningful fallback value. +COMFORTABLE_LINE_HEIGHT = 15 +COMPACT_LINE_HEIGHT = 10 + +exports.COMFORTABLE_LINE_HEIGHT = COMFORTABLE_LINE_HEIGHT +exports.COMPACT_LINE_HEIGHT = COMPACT_LINE_HEIGHT + +return exports diff --git a/packages/react-devtools-shared/src/devtools/ProfilerStore.lua b/packages/react-devtools-shared/src/devtools/ProfilerStore.lua new file mode 100644 index 00000000..e9e87018 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/ProfilerStore.lua @@ -0,0 +1,292 @@ +--!strict +--[[* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + ]] + +local Packages = script.Parent.Parent.Parent +local LuauPolyfill = require(Packages.LuauPolyfill) +local Array = LuauPolyfill.Array +local Map = LuauPolyfill.Map +local Set = LuauPolyfill.Set +local console = LuauPolyfill.console +type Array = LuauPolyfill.Array +type Map = LuauPolyfill.Map +type Object = LuauPolyfill.Object +type Set = LuauPolyfill.Set + +local EventEmitter = require(script.Parent.Parent.events) +type EventEmitter = EventEmitter.EventEmitter + +local prepareProfilingDataFrontendFromBackendAndStore = + require(script.Parent.views.Profiler.utils).prepareProfilingDataFrontendFromBackendAndStore + +local devtoolsTypes = require(script.Parent.types) +type ProfilingCache = devtoolsTypes.ProfilingCache +export type ProfilerStore = devtoolsTypes.ProfilerStore +type Store = devtoolsTypes.Store + +local Bridge = require(script.Parent.Parent.bridge) +type FrontendBridge = Bridge.FrontendBridge + +local backendTypes = require(script.Parent.Parent.backend.types) +type ProfilingDataBackend = backendTypes.ProfilingDataBackend + +local profilerTypes = require(script.Parent.views.Profiler.types) +type CommitDataFrontend = profilerTypes.CommitDataFrontend +type ProfilingDataForRootFrontend = profilerTypes.ProfilingDataForRootFrontend +type ProfilingDataFrontend = profilerTypes.ProfilingDataFrontend +type SnapshotNode = profilerTypes.SnapshotNode + +type ProfilerStore_statics = { + new: (bridge: FrontendBridge, store: Store, defaultIsProfiling: boolean) -> ProfilerStore, + __index: {}, +} + +local ProfilingCache = require(script.Parent.ProfilingCache) + +local ProfilerStore: ProfilerStore & ProfilerStore_statics = ( + setmetatable({}, { __index = EventEmitter }) :: any +) :: ProfilerStore & ProfilerStore_statics +ProfilerStore.__index = ProfilerStore + +function ProfilerStore.new(bridge: FrontendBridge, store: Store, defaultIsProfiling: boolean): ProfilerStore + local profilerStore: ProfilerStore = setmetatable(EventEmitter.new() :: any, ProfilerStore) + profilerStore._dataBackends = {} + profilerStore._dataFrontend = nil + profilerStore._initialRendererIDs = Set.new() + profilerStore._initialSnapshotsByRootID = Map.new() + profilerStore._inProgressOperationsByRootID = Map.new() + profilerStore._isProfiling = defaultIsProfiling + profilerStore._rendererIDsThatReportedProfilingData = Set.new() + profilerStore._rendererQueue = Set.new() + profilerStore._bridge = bridge + profilerStore._store = store + + function profilerStore:_takeProfilingSnapshotRecursive( + elementID: number, + profilingSnapshots: Map + ) + local element = self._store:getElementByID(elementID) + if element ~= nil then + local snapshotNode: SnapshotNode = { + id = elementID, + children = Array.slice(element.children, 0), + displayName = element.displayName, + hocDisplayNames = element.hocDisplayNames, + key = element.key, + type = element.type, + } + profilingSnapshots:set(elementID, snapshotNode) + Array.forEach(element.children, function(childID) + return self:_takeProfilingSnapshotRecursive(childID, profilingSnapshots) + end) + end + end + function profilerStore:onBridgeOperations(operations: Array) + -- The first two values are always rendererID and rootID + local rendererID = operations[ + 1 --[[ ROBLOX adaptation: added 1 to array index ]] + ] + local rootID = operations[ + 2 --[[ ROBLOX adaptation: added 1 to array index ]] + ] + if self._isProfiling then + local profilingOperations = self._inProgressOperationsByRootID:get(rootID) + if profilingOperations == nil then + profilingOperations = { operations } + -- ROBLOX FIXME Luau: nil-ability always remove due to assignment if nil + self._inProgressOperationsByRootID:set(rootID, profilingOperations :: Array>) + else + table.insert(profilingOperations, operations) + end + + if not self._initialRendererIDs:has(rendererID) then + self._initialRendererIDs:add(rendererID) + end + + if not self._initialSnapshotsByRootID:has(rootID) then + self._initialSnapshotsByRootID:set(rootID, Map.new()) + end + self._rendererIDsThatReportedProfilingData:add(rendererID) + end + end + function profilerStore:onBridgeProfilingData(dataBackend: ProfilingDataBackend) + if self._isProfiling then + -- This should never happen, but if it does- ignore previous profiling data. + return + end + local rendererID = dataBackend.rendererID + if not self._rendererQueue:has(rendererID) then + error(string.format('Unexpected profiling data update from renderer "%s"', tostring(rendererID))) + end + table.insert(self._dataBackends, dataBackend) + self._rendererQueue:delete(rendererID) + if self._rendererQueue.size == 0 then + self._dataFrontend = prepareProfilingDataFrontendFromBackendAndStore( + self._dataBackends, + self._inProgressOperationsByRootID, + self._initialSnapshotsByRootID + ) + Array.splice(self._dataBackends, 0) + self:emit("isProcessingData") + end + end + function profilerStore:onBridgeShutdown() + self._bridge:removeListener("operations", self.onBridgeOperations) + self._bridge:removeListener("profilingData", self.onBridgeProfilingData) + self._bridge:removeListener("profilingStatus", self.onProfilingStatus) + self._bridge:removeListener("shutdown", self.onBridgeShutdown) + end + function profilerStore:onProfilingStatus(isProfiling: boolean) + if isProfiling then + Array.splice(self._dataBackends, 0) + self._dataFrontend = nil + self._initialRendererIDs:clear() + self._initialSnapshotsByRootID:clear() + self._inProgressOperationsByRootID:clear() + self._rendererIDsThatReportedProfilingData:clear() + self._rendererQueue:clear() + -- Record all renderer IDs initially too (in case of unmount) + -- eslint-disable-next-line no-for-of-loops/no-for-of-loops + for _, rendererID in self._store:getRootIDToRendererID() do + if not self._initialRendererIDs:has(rendererID) then + self._initialRendererIDs:add(rendererID) + end + end + -- Record snapshot of tree at the time profiling is started. + -- This info is required to handle cases of e.g. nodes being removed during profiling. + for _, rootID in self._store:getRoots() do + local profilingSnapshots = Map.new() + self._initialSnapshotsByRootID:set(rootID, profilingSnapshots) + self:_takeProfilingSnapshotRecursive(rootID, profilingSnapshots) + end + end + if self._isProfiling ~= isProfiling then + self._isProfiling = isProfiling -- Invalidate suspense cache if profiling data is being (re-)recorded. + -- Note that we clear again, in case any views read from the cache while profiling. + -- (That would have resolved a now-stale value without any profiling data.) + self._cache:invalidate() + self:emit("isProfiling") -- If we've just finished a profiling session, we need to fetch data stored in each renderer interface + -- and re-assemble it on the front-end into a format (ProfilingDataFrontend) that can power the Profiler UI. + -- During this time, DevTools UI should probably not be interactive. + if not isProfiling then + Array.splice(self._dataBackends, 0) + self._rendererQueue:clear() -- Only request data from renderers that actually logged it. + -- This avoids unnecessary bridge requests and also avoids edge case mixed renderer bugs. + -- (e.g. when v15 and v16 are both present) + for _, rendererID in self._rendererIDsThatReportedProfilingData do + if not self._rendererQueue:has(rendererID) then + self._rendererQueue:add(rendererID) + self._bridge:send("getProfilingData", { + rendererID = rendererID, + }) + end + end + self:emit("isProcessingData") + end + end + end + + bridge:addListener("operations", function(...) + return profilerStore:onBridgeOperations(...) + end) + bridge:addListener("profilingData", function(...) + return profilerStore:onBridgeProfilingData(...) + end) + bridge:addListener("profilingStatus", function(...) + return profilerStore:onProfilingStatus(...) + end) + bridge:addListener("shutdown", function(...) + return profilerStore:onBridgeShutdown(...) + end) + + -- It's possible that profiling has already started (e.g. "reload and start profiling") + -- so the frontend needs to ask the backend for its status after mounting. + bridge:send("getProfilingStatus") + profilerStore._cache = ProfilingCache.new(profilerStore) + + return profilerStore +end +function ProfilerStore:getCommitData(rootID: number, commitIndex: number): CommitDataFrontend + if self._dataFrontend ~= nil then + local dataForRoot = self._dataFrontend.dataForRoots:get(rootID) + if dataForRoot ~= nil then + local commitDatum = dataForRoot.commitData[commitIndex] + if commitDatum ~= nil then + return commitDatum + end + end + end + error( + string.format('Could not find commit data for root "%s" and commit %s', tostring(rootID), tostring(commitIndex)) + ) +end +function ProfilerStore:getDataForRoot(rootID: number): ProfilingDataForRootFrontend + if self._dataFrontend ~= nil then + local dataForRoot = self._dataFrontend.dataForRoots:get(rootID) + if dataForRoot ~= nil then + return dataForRoot + end + end + error(string.format('Could not find commit data for root "%s"', tostring(rootID))) +end +function ProfilerStore:didRecordCommits(): boolean + return self._dataFrontend ~= nil and self._dataFrontend.dataForRoots.size > 0 +end +function ProfilerStore:isProcessingData(): boolean + return self._rendererQueue.size > 0 or #self._dataBackends > 0 +end +function ProfilerStore:isProfiling(): boolean + return self._isProfiling +end +function ProfilerStore:profilingCache(): ProfilingCache + return self._cache +end +function ProfilerStore:profilingData(value: ProfilingDataFrontend | nil): ...ProfilingDataFrontend? + if value == nil then + return self._dataFrontend + end + + if self._isProfiling then + console.warn("Profiling data cannot be updated while profiling is in progress.") + return + end + Array.splice(self._dataBackends, 0) + self._dataFrontend = value + self._initialRendererIDs:clear() + self._initialSnapshotsByRootID:clear() + self._inProgressOperationsByRootID:clear() + self._cache:invalidate() + self:emit("profilingData") + return +end +function ProfilerStore:clear(): ...any? + Array.splice(self._dataBackends, 0) + self._dataFrontend = nil + self._initialRendererIDs:clear() + self._initialSnapshotsByRootID:clear() + self._inProgressOperationsByRootID:clear() + self._rendererQueue:clear() -- Invalidate suspense cache if profiling data is being (re-)recorded. + -- Note that we clear now because any existing data is "stale". + self._cache:invalidate() + self:emit("profilingData") +end +function ProfilerStore:startProfiling(): ...any? + self._bridge:send("startProfiling", self._store:getRecordChangeDescriptions()) -- Don't actually update the local profiling boolean yet! + -- Wait for onProfilingStatus() to confirm the status has changed. + -- This ensures the frontend and backend are in sync wrt which commits were profiled. + -- We do this to avoid mismatches on e.g. CommitTreeBuilder that would cause errors. +end +function ProfilerStore:stopProfiling(): ...any? + self._bridge:send("stopProfiling") -- Don't actually update the local profiling boolean yet! + -- Wait for onProfilingStatus() to confirm the status has changed. + -- This ensures the frontend and backend are in sync wrt which commits were profiled. + -- We do this to avoid mismatches on e.g. CommitTreeBuilder that would cause errors. +end + +return ProfilerStore diff --git a/packages/react-devtools-shared/src/devtools/ProfilingCache.lua b/packages/react-devtools-shared/src/devtools/ProfilingCache.lua new file mode 100644 index 00000000..8f5d3c40 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/ProfilingCache.lua @@ -0,0 +1,125 @@ +--!strict +--[[* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + ]] +local Packages = script.Parent.Parent.Parent +local LuauPolyfill = require(Packages.LuauPolyfill) +local Array = LuauPolyfill.Array +local Map = LuauPolyfill.Map + +type Map = LuauPolyfill.Map +type Array = LuauPolyfill.Array + +local ProfilerViews = script.Parent.views.Profiler + +local CommitTreeBuilderModule = require(ProfilerViews.CommitTreeBuilder) +local getCommitTree = CommitTreeBuilderModule.getCommitTree +local invalidateCommitTrees = CommitTreeBuilderModule.invalidateCommitTrees + +local FlamegraphChartBuilderModule = require(ProfilerViews.FlamegraphChartBuilder) +local getFlamegraphChartData = FlamegraphChartBuilderModule.getChartData +local invalidateFlamegraphChartData = FlamegraphChartBuilderModule.invalidateChartData + +local InteractionsChartBuilderModule = require(ProfilerViews.InteractionsChartBuilder) +local getInteractionsChartData = InteractionsChartBuilderModule.getChartData +local invalidateInteractionsChartData = InteractionsChartBuilderModule.invalidateChartData + +local RankedChartBuilderModule = require(ProfilerViews.RankedChartBuilder) +local getRankedChartData = RankedChartBuilderModule.getChartData +local invalidateRankedChartData = RankedChartBuilderModule.invalidateChartData + +local typesModule = require(ProfilerViews.types) +type CommitTree = typesModule.CommitTree + +type FlamegraphChartData = FlamegraphChartBuilderModule.ChartData +type InteractionsChartData = InteractionsChartBuilderModule.ChartData +type RankedChartData = RankedChartBuilderModule.ChartData + +local devtoolsTypes = require(script.Parent.types) +type ProfilingCache = devtoolsTypes.ProfilingCache +type ProfilerStore = devtoolsTypes.ProfilerStore + +type ProfilingCache_statics = { new: (profilerStore: ProfilerStore) -> ProfilingCache } + +local ProfilingCache = {} :: ProfilingCache & ProfilingCache_statics; +(ProfilingCache :: any).__index = ProfilingCache + +function ProfilingCache.new(profilerStore: ProfilerStore): ProfilingCache + local profilingCache: ProfilingCache = (setmetatable({}, ProfilingCache) :: any) :: ProfilingCache + profilingCache._fiberCommits = Map.new() + profilingCache._profilerStore = profilerStore + + function profilingCache:getCommitTree(ref: { commitIndex: number, rootID: number }) + local commitIndex, rootID = ref.commitIndex, ref.rootID + return getCommitTree({ + commitIndex = commitIndex, + profilerStore = self._profilerStore, + rootID = rootID, + }) + end + function profilingCache:getFiberCommits(ref: { fiberID: number, rootID: number }): Array + local fiberID, rootID = ref.fiberID, ref.rootID + local cachedFiberCommits = self._fiberCommits:get(fiberID) + if cachedFiberCommits ~= nil then + return cachedFiberCommits + end + local fiberCommits = {} :: Array + local dataForRoot = self._profilerStore:getDataForRoot(rootID) + Array.forEach(dataForRoot.commitData, function(commitDatum, commitIndex) + if commitDatum.fiberActualDurations:has(fiberID) then + table.insert(fiberCommits, commitIndex) + end + end) + self._fiberCommits:set(fiberID, fiberCommits) + return fiberCommits + end + function profilingCache:getFlamegraphChartData(ref: { + commitIndex: number, + commitTree: CommitTree, + rootID: number, + }): FlamegraphChartData + local commitIndex, commitTree, rootID = ref.commitIndex, ref.commitTree, ref.rootID + return getFlamegraphChartData({ + commitIndex = commitIndex, + commitTree = commitTree, + profilerStore = self._profilerStore, + rootID = rootID, + }) + end + function profilingCache:getInteractionsChartData(ref: { rootID: number }): InteractionsChartData + local rootID = ref.rootID + return getInteractionsChartData({ + profilerStore = self._profilerStore, + rootID = rootID, + }) + end + function profilingCache:getRankedChartData(ref: { + commitIndex: number, + commitTree: CommitTree, + rootID: number, + }): RankedChartData + local commitIndex, commitTree, rootID = ref.commitIndex, ref.commitTree, ref.rootID + return getRankedChartData({ + commitIndex = commitIndex, + commitTree = commitTree, + profilerStore = self._profilerStore, + rootID = rootID, + }) + end + + return profilingCache +end +function ProfilingCache:invalidate() + self._fiberCommits:clear() + invalidateCommitTrees() + invalidateFlamegraphChartData() + invalidateInteractionsChartData() + invalidateRankedChartData() +end + +return ProfilingCache diff --git a/packages/react-devtools-shared/src/devtools/cache.lua b/packages/react-devtools-shared/src/devtools/cache.lua new file mode 100644 index 00000000..f55aede6 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/cache.lua @@ -0,0 +1,210 @@ +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/devtools/cache.js +--[[* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. +]] + +local Packages = script.Parent.Parent.Parent +local LuauPolyfill = require(Packages.LuauPolyfill) +local Error = LuauPolyfill.Error +local Map = LuauPolyfill.Map +type Map = LuauPolyfill.Map +local WeakMap = LuauPolyfill.WeakMap +type WeakMap = LuauPolyfill.WeakMap + +local ReactTypes = require(Packages.Shared) +export type Thenable = ReactTypes.Thenable + +local React = require(Packages.React) +local createContext = React.createContext + +-- Cache implementation was forked from the React repo: +-- https://github.com/facebook/react/blob/master/packages/react-cache/src/ReactCache.js +-- +-- This cache is simpler than react-cache in that: +-- 1. Individual items don't need to be invalidated. +-- Profiling data is invalidated as a whole. +-- 2. We didn't need the added overhead of an LRU cache. +-- The size of this cache is bounded by how many renders were profiled, +-- and it will be fully reset between profiling sessions. + +-- ROBLOX deviation START: Suspender needs a generic param to be type compatible with Thenable +export type Suspender = { + andThen: (self: Thenable, onFulfill: (R) -> () | U, onReject: (error: any) -> () | U) -> (), +} +-- ROBLOX deviation END + +type PendingResult = { + status: number, -- ROBLOX TODO: Luau doesn't support literal: 0 + value: Suspender, +} + +type ResolvedResult = { + status: number, -- ROBLOX TODO: Luau doesn't support literal: 1 + value: Value, +} + +type RejectedResult = { + status: number, -- ROBLOX TODO: Luau doesn't support literal: 2 + value: any, +} + +type Result = PendingResult | ResolvedResult | RejectedResult + +export type Resource = { + clear: () -> (), + invalidate: (Key) -> (), + read: (Input) -> Value, + preload: (Input) -> (), + write: (Key, Value) -> (), +} + +local Pending = 0 +local Resolved = 1 +local Rejected = 2 + +local ReactCurrentDispatcher = React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentDispatcher + +local function readContext(Context, observedBits: boolean?) + local dispatcher = ReactCurrentDispatcher.current + if dispatcher == nil then + error( + Error.new( + "react-cache: read and preload may only be called from within a " + .. "component's render. They are not supported in event handlers or " + .. "lifecycle methods." + ) + ) + end + assert(dispatcher ~= nil, "assert needed until Luau understands if nil then error()") + return dispatcher.readContext(Context, observedBits) +end + +local CacheContext = createContext(nil) + +type Config = { useWeakMap: boolean? } + +-- ROBLOX deviation START: only use WeakMap +local entries: Map, WeakMap> = Map.new() +local resourceConfigs: Map, Config> = Map.new() + +local function getEntriesForResource(resource: any): WeakMap + local entriesForResource = entries:get(resource) :: WeakMap + if entriesForResource == nil then + -- ROBLOX deviation START: skip the check and just use WeakMap + -- local config = resourceConfigs:get(resource) + entriesForResource = WeakMap.new() + -- ROBLOX deviation END + + entries:set(resource, entriesForResource :: WeakMap) + end + + return entriesForResource :: WeakMap +end +-- ROBLOX deviation END + +local function accessResult(resource: any, fetch: (Input) -> Thenable, input: Input, key: Key): Result + local entriesForResource = getEntriesForResource(resource) + local entry = entriesForResource:get(key) + + if entry == nil then + local thenable = fetch(input) + + local newResult: PendingResult + + thenable:andThen(function(value) + if newResult.status == Pending then + local resolvedResult: ResolvedResult = newResult :: any + + resolvedResult.status = Resolved + resolvedResult.value = value + end + -- ROBLOX deviation START: explicit return type + -- end, function(error_) + end, function(error_): () + -- ROBLOX deviation END + if newResult.status == Pending then + local rejectedResult: RejectedResult = newResult :: any + + rejectedResult.status = Rejected + rejectedResult.value = error_ + end + end) + + newResult = { + status = Pending, + -- ROBLOX deviation START: needs cast + -- value = thenable, + value = thenable :: any, + -- ROBLOX deviation END + } + entriesForResource:set(key, newResult) + return newResult + else + return entry + end +end + +local exports = {} + +exports.createResource = function( + fetch: (Input) -> Thenable, + hashInput: (Input) -> Key, + _config: Config? +): Resource + local config = _config or {} + -- ROBLOX deviation: define before reference + local resource + resource = { + clear = function(): () + entries[resource] = nil + end, + invalidate = function(key: Key): () + local entriesForResource = getEntriesForResource(resource) + entriesForResource[key] = nil + end, + read = function(input: Input): Value + readContext(CacheContext) + local key = hashInput(input) + local result: Result = accessResult(resource, fetch, input, key) + if result.status == Pending then + error(result.value) + elseif result.status == Resolved then + return result.value + elseif result.status == Rejected then + error(result.value) + else + -- Should be unreachable + return nil :: any + end + end, + preload = function(input: Input): () + readContext(CacheContext) + + local key = hashInput(input) + accessResult(resource, fetch, input, key) + end, + write = function(key: Key, value: Value): () + local entriesForResource = getEntriesForResource(resource) + local resolvedResult: ResolvedResult = { + status = Resolved, + value = value, + } + + entriesForResource:set(key, resolvedResult) + end, + } + + resourceConfigs:set(resource, config) + + return resource +end + +exports.invalidateResources = function(): () + entries:clear() +end + +return exports diff --git a/packages/react-devtools-shared/src/devtools/init.lua b/packages/react-devtools-shared/src/devtools/init.lua new file mode 100644 index 00000000..2b4bc145 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/init.lua @@ -0,0 +1,27 @@ +--[[ + * Copyright (c) Roblox Corporation. All rights reserved. + * Licensed under the MIT License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://opensource.org/licenses/MIT + * + * 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. +]] + +return { + utils = require(script.utils), + store = require(script.store), + cache = require(script.cache), + devtools = { + Components = { + views = { + types = require(script.views.Components.types), + }, + }, + }, +} diff --git a/packages/react-devtools-shared/src/devtools/store.lua b/packages/react-devtools-shared/src/devtools/store.lua new file mode 100644 index 00000000..ad5c496e --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/store.lua @@ -0,0 +1,1074 @@ +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/devtools/store.js +--[[* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. +]] + +local Packages = script.Parent.Parent.Parent + +local LuauPolyfill = require(Packages.LuauPolyfill) +local inspect = LuauPolyfill.util.inspect +local Array = LuauPolyfill.Array +local Error = LuauPolyfill.Error +local Map = LuauPolyfill.Map +local Object = LuauPolyfill.Object +local Set = LuauPolyfill.Set + +type Array = LuauPolyfill.Array +type Map = LuauPolyfill.Map +type Object = LuauPolyfill.Object +type Set = LuauPolyfill.Set +local console = require(Packages.Shared).console + +local EventEmitter = require(script.Parent.Parent.events) +type EventEmitter = EventEmitter.EventEmitter +local constants = require(script.Parent.Parent.constants) +local TREE_OPERATION_ADD = constants.TREE_OPERATION_ADD +local TREE_OPERATION_REMOVE = constants.TREE_OPERATION_REMOVE +local TREE_OPERATION_REORDER_CHILDREN = constants.TREE_OPERATION_REORDER_CHILDREN +local TREE_OPERATION_UPDATE_TREE_BASE_DURATION = constants.TREE_OPERATION_UPDATE_TREE_BASE_DURATION +local types = require(script.Parent.Parent.types) +local ElementTypeRoot = types.ElementTypeRoot +local utils = require(script.Parent.Parent.utils) +local getSavedComponentFilters = utils.getSavedComponentFilters +local saveComponentFilters = utils.saveComponentFilters +local separateDisplayNameAndHOCs = utils.separateDisplayNameAndHOCs +local shallowDiffers = utils.shallowDiffers +-- ROBLOX deviation: don't use string encoding +-- local utfDecodeString = utils.utfDecodeString +local storage = require(script.Parent.Parent.storage) +local localStorageGetItem = storage.localStorageGetItem +local localStorageSetItem = storage.localStorageSetItem +local __DEBUG__ = constants.__DEBUG__ + +local ProfilerStore = require(script.Parent.ProfilerStore) +type ProfilerStore = ProfilerStore.ProfilerStore + +local ComponentsTypes = require(script.Parent.Parent.devtools.views.Components.types) +type Element = ComponentsTypes.Element +local Types = require(script.Parent.Parent.types) +type ComponentFilter = Types.ComponentFilter +type ElementType = Types.ElementType +local Bridge = require(script.Parent.Parent.bridge) +type FrontendBridge = Bridge.FrontendBridge + +local devtoolsTypes = require(script.Parent.types) +type Store = devtoolsTypes.Store +type Capabilities = devtoolsTypes.Capabilities + +local debug_ = function(methodName, ...) + if __DEBUG__ then + print("Store", methodName, ...) + end +end + +local LOCAL_STORAGE_COLLAPSE_ROOTS_BY_DEFAULT_KEY = "React::DevTools::collapseNodesByDefault" +local LOCAL_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY = "React::DevTools::recordChangeDescriptions" + +type Config = { + isProfiling: boolean?, + supportsNativeInspection: boolean?, + supportsReloadAndProfile: boolean?, + supportsProfiling: boolean?, + supportsTraceUpdates: boolean?, +} + +-- /** +-- * The store is the single source of truth for updates from the backend. +-- * ContextProviders can subscribe to the Store for specific things they want to provide. +-- */ + +-- ROBLOX deviation: equivalent of sub-class +type Store_static = { + new: (bridge: FrontendBridge, config: Config?) -> Store, +} +local Store: Store & Store_static = (setmetatable({}, { __index = EventEmitter }) :: any) :: Store & Store_static +local StoreMetatable = { __index = Store } + +function Store.new(bridge: FrontendBridge, config: Config?): Store + local self = setmetatable(EventEmitter.new() :: any, StoreMetatable) :: any + config = config or {} + + -- ROBLOX deviation: define fields in constructor + self._bridge = bridge + + -- Should new nodes be collapsed by default when added to the tree? + self._collapseNodesByDefault = true + + self._componentFilters = {} + + -- At least one of the injected renderers contains (DEV only) owner metadata. + self._hasOwnerMetadata = false + + -- Map of ID to (mutable) Element. + -- Elements are mutated to avoid excessive cloning during tree updates. + -- The InspectedElementContext also relies on this mutability for its WeakMap usage. + self._idToElement = Map.new() :: Map + + -- Should the React Native style editor panel be shown? + self._isNativeStyleEditorSupported = false + + -- Can the backend use the Storage API (e.g. localStorage)? + -- If not, features like reload-and-profile will not work correctly and must be disabled. + self._isBackendStorageAPISupported = false + + self._nativeStyleEditorValidAttributes = nil + + -- Map of element (id) to the set of elements (ids) it owns. + -- This map enables getOwnersListForElement() to avoid traversing the entire tree. + self._ownersMap = Map.new() :: Map> + + self._recordChangeDescriptions = false + + -- Incremented each time the store is mutated. + -- This enables a passive effect to detect a mutation between render and commit phase. + self._revision = 0 + + -- This Array must be treated as immutable! + -- Passive effects will check it for changes between render and mount. + self._roots = {} :: Array + + self._rootIDToCapabilities = Map.new() :: Map + + -- Renderer ID is needed to support inspection fiber props, state, and hooks. + self._rootIDToRendererID = Map.new() :: Map + + -- These options may be initially set by a confiugraiton option when constructing the Store. + -- In the case of "supportsProfiling", the option may be updated based on the injected renderers. + self._supportsNativeInspection = true + self._supportsProfiling = false + self._supportsReloadAndProfile = false + self._supportsTraceUpdates = false + + self._unsupportedRendererVersionDetected = false + + -- Total number of visible elements (within all roots). + -- Used for windowing purposes. + self._weightAcrossRoots = 0 + + if __DEBUG__ then + debug_("constructor", "subscribing to Bridge") + end + + self._collapseNodesByDefault = localStorageGetItem(LOCAL_STORAGE_COLLAPSE_ROOTS_BY_DEFAULT_KEY) == "true" + + self._recordChangeDescriptions = localStorageGetItem(LOCAL_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY) == "true" + + self._componentFilters = getSavedComponentFilters() + + local isProfiling = false + if config ~= nil then + isProfiling = (config :: Config).isProfiling == true + + local supportsNativeInspection = (config :: Config).supportsNativeInspection + local supportsProfiling = (config :: Config).supportsProfiling + local supportsReloadAndProfile = (config :: Config).supportsReloadAndProfile + local supportsTraceUpdates = (config :: Config).supportsTraceUpdates + + self._supportsNativeInspection = supportsNativeInspection ~= false + if supportsProfiling then + self._supportsProfiling = true + end + if supportsReloadAndProfile then + self._supportsReloadAndProfile = true + end + if supportsTraceUpdates then + self._supportsTraceUpdates = true + end + end + + self._profilerStore = ProfilerStore.new(bridge, self, isProfiling) + + -- ROBLOX deviation: bind methods which don't pass self to this instance + self._onBridgeOperations = self.onBridgeOperations + self.onBridgeOperations = function(...) + self:_onBridgeOperations(...) + end + self._onBridgeOverrideComponentFilters = self.onBridgeOverrideComponentFilters + self.onBridgeOverrideComponentFilters = function(...) + self:_onBridgeOverrideComponentFilters(...) + end + self._onBridgeShutdown = self.onBridgeShutdown + self.onBridgeShutdown = function(...) + self:_onBridgeShutdown(...) + end + self._onBridgeStorageSupported = self.onBridgeStorageSupported + self.onBridgeStorageSupported = function(...) + self:_onBridgeStorageSupported(...) + end + self._onBridgeNativeStyleEditorSupported = self.onBridgeNativeStyleEditorSupported + self.onBridgeNativeStyleEditorSupported = function(...) + self:_onBridgeNativeStyleEditorSupported(...) + end + self._onBridgeUnsupportedRendererVersion = self.onBridgeUnsupportedRendererVersion + self.onBridgeUnsupportedRendererVersion = function(...) + self:_onBridgeUnsupportedRendererVersion(...) + end + + bridge:addListener("operations", self.onBridgeOperations) + bridge:addListener("overrideComponentFilters", self.onBridgeOverrideComponentFilters) + bridge:addListener("shutdown", self.onBridgeShutdown) + bridge:addListener("isBackendStorageAPISupported", self.onBridgeStorageSupported) + bridge:addListener("isNativeStyleEditorSupported", self.onBridgeNativeStyleEditorSupported) + bridge:addListener("unsupportedRendererVersion", self.onBridgeUnsupportedRendererVersion) + + return self +end + +-- This is only used in tests to avoid memory leaks. +function Store:assertExpectedRootMapSizes() + if #self._roots == 0 then + -- The only safe time to assert these maps are empty is when the store is empty. + self:assertMapSizeMatchesRootCount(self._idToElement, "_idToElement") + self:assertMapSizeMatchesRootCount(self._ownersMap, "_ownersMap") + end + + -- These maps should always be the same size as the number of roots + self:assertMapSizeMatchesRootCount(self._rootIDToCapabilities, "_rootIDToCapabilities") + self:assertMapSizeMatchesRootCount(self._rootIDToRendererID, "_rootIDToRendererID") +end + +-- This is only used in tests to avoid memory leaks. +function Store:assertMapSizeMatchesRootCount(map: Map, mapName: string) + local expectedSize = #self._roots + if map.size ~= expectedSize then + error( + Error.new( + string.format( + "Expected %s to contain %s items, but it contains %s items\n\n%s", + mapName, + tostring(expectedSize), + tostring(map.size), + inspect(map, { depth = 20 }) + ) + ) + ) + end +end + +-- ROBLOX deviation: get / setters not supported in luau +function Store:getCollapseNodesByDefault(): boolean + return self._collapseNodesByDefault +end + +function Store:setCollapseNodesByDefault(value: boolean) + self._collapseNodesByDefault = value + + localStorageSetItem(LOCAL_STORAGE_COLLAPSE_ROOTS_BY_DEFAULT_KEY, if value then "true" else "false") + self:emit("collapseNodesByDefault") +end +function Store:getComponentFilters(): Array + return self._componentFilters +end + +function Store:setComponentFilters(value: Array): () + if self._profilerStore:isProfiling() then + -- Re-mounting a tree while profiling is in progress might break a lot of assumptions. + -- If necessary, we could support this- but it doesn't seem like a necessary use case. + error("Cannot modify filter preferences while profiling") + end + + -- Filter updates are expensive to apply (since they impact the entire tree). + -- Let's determine if they've changed and avoid doing this work if they haven't. + local prevEnabledComponentFilters = Array.filter(self._componentFilters, function(filter) + return filter.isEnabled + end) + local nextEnabledComponentFilters = Array.filter(value, function(filter) + return filter.isEnabled + end) + local haveEnabledFiltersChanged = #prevEnabledComponentFilters ~= #nextEnabledComponentFilters + + if not haveEnabledFiltersChanged then + -- ROBLOX deviation: 1-indexing use 1 not 0 + for i = 1, #nextEnabledComponentFilters do + local prevFilter = prevEnabledComponentFilters[i] + local nextFilter = nextEnabledComponentFilters[i] + + if shallowDiffers(prevFilter, nextFilter) then + haveEnabledFiltersChanged = true + break + end + end + end + + self._componentFilters = value + + -- Update persisted filter preferences stored in localStorage. + saveComponentFilters(value) + + -- Notify the renderer that filter prefernces have changed. + -- This is an expensive opreation; it unmounts and remounts the entire tree, + -- so only do it if the set of enabled component filters has changed. + if haveEnabledFiltersChanged then + self._bridge:send("updateComponentFilters", value) + end + + self:emit("componentFilters") +end +function Store:getHasOwnerMetadata(): boolean + return self._hasOwnerMetadata +end +function Store:getNativeStyleEditorValidAttributes(): Array | nil + return self._nativeStyleEditorValidAttributes +end +function Store:getNumElements(): number + return self._weightAcrossRoots +end +function Store:getProfilerStore(): ProfilerStore + return self._profilerStore +end +function Store:getRecordChangeDescriptions(): boolean + return self._recordChangeDescriptions +end +function Store:setRecordChangeDescriptions(value: boolean): () + self._recordChangeDescriptions = value + + localStorageSetItem(LOCAL_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY, if value then "true" else "false") + self:emit("recordChangeDescriptions") +end +function Store:getRevision(): number + return self._revision +end +function Store:getRootIDToRendererID(): Map + return self._rootIDToRendererID +end +function Store:getRoots(): Array + return self._roots +end +function Store:getSupportsNativeInspection(): boolean + return self._supportsNativeInspection +end +function Store:getSupportsNativeStyleEditor(): boolean + return self._isNativeStyleEditorSupported +end +function Store:getSupportsProfiling(): boolean + return self._supportsProfiling +end +function Store:getSupportsReloadAndProfile(): boolean + return self._supportsReloadAndProfile and self._isBackendStorageAPISupported +end +function Store:getSupportsTraceUpdates(): boolean + return self._supportsTraceUpdates +end +function Store:getUnsupportedRendererVersionDetected(): boolean + return self._unsupportedRendererVersionDetected +end +function Store:containsElement(id: number): boolean + return self._idToElement:get(id) ~= nil +end +function Store:getElementAtIndex(index: number): Element? + if index < 0 or index >= self:getNumElements() then + console.warn( + string.format("Invalid index %d specified; store contains %d items.", index, self:getNumElements()) + ) + return nil + end + + -- Find which root this element is in... + local rootID + local root + local rootWeight = 0 + + -- ROBLOX deviation: 1-indexing use 1 not 0 + for i = 1, #self._roots do + rootID = self._roots[i] + root = (self._idToElement:get(rootID) :: any) :: Element + if #root.children == 0 then + continue + elseif rootWeight + root.weight > index then + break + else + rootWeight += root.weight + end + end + + -- Find the element in the tree using the weight of each node... + -- Skip over the root itself, because roots aren't visible in the Elements tree. + local currentElement = (root :: any) :: Element + local currentWeight = rootWeight - 1 + + while index ~= currentWeight do + local numChildren = #currentElement.children + + for i = 1, numChildren do + local childID = currentElement.children[i] + local child = (self._idToElement:get(childID) :: any) :: Element + local childWeight = if child.isCollapsed then 1 else child.weight + + if index <= currentWeight + childWeight then + currentWeight += 1 + currentElement = child + break + else + currentWeight += childWeight + end + end + end + return currentElement or nil +end + +function Store:getElementIDAtIndex(index: number): number | nil + local element: Element? = self:getElementAtIndex(index) + + return (function(): number? + if element == nil then + return nil + end + return (element :: Element).id + end)() +end +function Store:getElementByID(id: number): Element | nil + local element = self._idToElement:get(id) + + if element == nil then + console.warn(string.format('No element found with id "%s"', tostring(id))) + return nil + end + + return element +end +function Store:getIndexOfElementID(id: number): number | nil + local element: Element? = self:getElementByID(id) + + if element == nil or (element :: Element).parentID == 0 then + return nil + end + + -- Walk up the tree to the root. + -- Increment the index by one for each node we encounter, + -- and by the weight of all nodes to the left of the current one. + -- This should be a relatively fast way of determining the index of a node within the tree. + local previousID = id + local currentID = (element :: Element).parentID + local index = 0 + + while true do + local current = (self._idToElement:get(currentID) :: any) :: Element + local children = current.children + + for i = 1, #children do + local childID = children[i] + if childID == previousID then + break + end + + local child = (self._idToElement:get(childID) :: any) :: Element + index += if child.isCollapsed then 1 else child.weight + end + + -- We found the root; stop crawling. + if current.parentID == 0 then + break + end + + index += 1 + previousID = current.id + currentID = current.parentID + end + + -- At this point, the current ID is a root (from the previous loop). + -- We also need to offset the index by previous root weights. + for i = 1, #self._roots do + local rootID = self._roots[i] + if rootID == currentID then + break + end + local root = (self._idToElement:get(rootID) :: any) :: Element + index += root.weight + end + + return index +end + +function Store:getOwnersListForElement(ownerID: number): Array + local list = {} + local element = self._idToElement:get(ownerID) + if element ~= nil then + table.insert(list, Object.assign({}, element, { depth = 0 })) + + local unsortedIDs = self._ownersMap:get(ownerID) + + -- ROBLOX FIXME Luau: without manual annotation: Types Set and nil cannot be compared with ~= because they do not have the same metatable + if unsortedIDs ~= nil then + local depthMap: Map = Map.new({ { ownerID, 0 } }) + + -- Items in a set are ordered based on insertion. + -- This does not correlate with their order in the tree. + -- So first we need to order them. + -- I wish we could avoid this sorting operation; we could sort at insertion time, + -- but then we'd have to pay sorting costs even if the owners list was never used. + -- Seems better to defer the cost, since the set of ids is probably pretty small. + local sortedIDs = Array.sort( + Array.from(unsortedIDs), + -- ROBLOX FIXME Luau: shouldn't need this annotation? + function(idA: number, idB: number) + return (self:getIndexOfElementID(idA) or 0) - (self:getIndexOfElementID(idB) or 0) + end + ) + + -- Next we need to determine the appropriate depth for each element in the list. + -- The depth in the list may not correspond to the depth in the tree, + -- because the list has been filtered to remove intermediate components. + -- Perhaps the easiest way to do this is to walk up the tree until we reach either: + -- (1) another node that's already in the tree, or (2) the root (owner) + -- at which point, our depth is just the depth of that node plus one. + for _, id in sortedIDs do + local innerElement = self._idToElement:get(id) + + if innerElement ~= nil then + local parentID = innerElement.parentID + local depth = 0 + + while parentID > 0 do + if parentID == ownerID or unsortedIDs:has(parentID) then + depth = depthMap:get(parentID) :: number + 1 + depthMap:set(id, depth) + break + end + local parent = self._idToElement:get(parentID) + if parent == nil then + break + end + -- ROBLOX FIXME Luau: need type states to understand parent isn't nil due to break + parentID = (parent :: Element).parentID + end + + if depth == 0 then + error("Invalid owners list") + end + + table.insert(list, Object.assign({}, innerElement, { depth = depth })) + end + end + end + end + + return list +end + +function Store:getRendererIDForElement(id: number): number | nil + local current = self._idToElement:get(id) + + while current ~= nil do + if current.parentID == 0 then + local rendererID = self._rootIDToRendererID:get(current.id) + if rendererID == nil then + return nil + end + return rendererID + else + current = self._idToElement:get(current.parentID) + end + end + + return nil +end + +function Store:getRootIDForElement(id: number): number | nil + local current = self._idToElement:get(id) + while current ~= nil do + if current.parentID == 0 then + return current.id + else + current = self._idToElement:get(current.parentID) + end + end + return nil +end + +function Store:isInsideCollapsedSubTree(id: number): boolean + local current = self._idToElement:get(id) + while current ~= nil do + if (current :: Element).parentID == 0 then + return false + else + current = self._idToElement:get(current.parentID) + if current ~= nil and (current :: Element).isCollapsed then + return true + end + end + end + return false +end + +-- TODO Maybe split this into two methods: expand() and collapse() +function Store:toggleIsCollapsed(id: number, isCollapsed: boolean): () + local didMutate = false + local element: Element? = self:getElementByID(id) + + if element ~= nil then + if isCollapsed then + if (element :: Element).type == ElementTypeRoot then + error("Root nodes cannot be collapsed") + end + if not (element :: Element).isCollapsed then + didMutate = true; + (element :: Element).isCollapsed = true + + local weightDelta = 1 - (element :: Element).weight + -- ROBLOX FIXME Luau: shouldn't need this annoatation, should infer correctly + local parentElement: Element? = (self._idToElement:get(element.parentID) :: any) :: Element + while parentElement ~= nil do + -- We don't need to break on a collapsed parent in the same way as the expand case below. + -- That's because collapsing a node doesn't "bubble" and affect its parents. + parentElement.weight += weightDelta + parentElement = self._idToElement:get(parentElement.parentID) + end + end + else + -- ROBLOX FIXME Luau: shouldn't need this annoatation, should infer correctly + local currentElement: Element? = element + + while currentElement ~= nil do + local oldWeight = if (currentElement :: Element).isCollapsed then 1 else currentElement.weight + + if (currentElement :: Element).isCollapsed then + didMutate = true; + (currentElement :: Element).isCollapsed = false + + local newWeight = if (currentElement :: Element).isCollapsed + then 1 + else (currentElement :: Element).weight + local weightDelta = newWeight - oldWeight + -- ROBLOX FIXME Luau: shouldn't need this annoatation, should infer correctly + local parentElement: Element? = (self._idToElement:get(currentElement.parentID) :: any) :: Element + + while parentElement ~= nil do + parentElement.weight += weightDelta + + if (parentElement :: Element).isCollapsed then + -- It's important to break on a collapsed parent when expanding nodes. + -- That's because expanding a node "bubbles" up and expands all parents as well. + -- Breaking in this case prevents us from over-incrementing the expanded weights. + break + end + parentElement = self._idToElement:get(parentElement.parentID) + end + end + + currentElement = if (currentElement :: Element).parentID ~= 0 + then self:getElementByID((currentElement :: Element).parentID) + else nil + end + end + + -- Only re-calculate weights and emit an "update" event if the store was mutated. + if didMutate then + local weightAcrossRoots = 0 + for _i, rootID in self._roots do + local elementById: Element? = self:getElementByID(rootID) + local weight = (elementById :: Element).weight + weightAcrossRoots = weightAcrossRoots + weight + end + self._weightAcrossRoots = weightAcrossRoots + + -- The Tree context's search reducer expects an explicit list of ids for nodes that were added or removed. + -- In this case, we can pass it empty arrays since nodes in a collapsed tree are still there (just hidden). + -- Updating the selected search index later may require auto-expanding a collapsed subtree though. + self:emit("mutated", { + {}, + {}, + }) + end + end +end + +function Store:_adjustParentTreeWeight(parentElement: Element | nil, weightDelta: number) + local isInsideCollapsedSubTree = false + + while parentElement ~= nil do + (parentElement :: Element).weight += weightDelta + + -- Additions and deletions within a collapsed subtree should not bubble beyond the collapsed parent. + -- Their weight will bubble up when the parent is expanded. + if (parentElement :: Element).isCollapsed then + isInsideCollapsedSubTree = true + break + end + + parentElement = (self._idToElement:get(parentElement.parentID) :: any) :: Element + end + + -- Additions and deletions within a collapsed subtree should not affect the overall number of elements. + if not isInsideCollapsedSubTree then + self._weightAcrossRoots += weightDelta + end +end + +function Store:onBridgeNativeStyleEditorSupported(options: { + isSupported: boolean, + validAttributes: Array, +}) + local isSupported, validAttributes = options.isSupported, options.validAttributes + + self._isNativeStyleEditorSupported = isSupported + self._nativeStyleEditorValidAttributes = validAttributes or nil + + self:emit("supportsNativeStyleEditor") +end + +function Store:onBridgeOperations(operations: Array): () + if __DEBUG__ then + console.groupCollapsed("onBridgeOperations") + debug_("onBridgeOperations", table.concat(operations, ",")) + end + + local haveRootsChanged = false + + -- The first two values are always rendererID and rootID + local rendererID = operations[1] + local addedElementIDs = {} + -- This is a mapping of removed ID -> parent ID: + local removedElementIDs = {} + -- We'll use the parent ID to adjust selection if it gets deleted. + -- ROBLOX deviation: 1-indexed means this is 3, not 2 + local i = 3 + local stringTable: Array = { + -- ROBLOX deviation: element 1 corresponds to empty string + "", -- ID = 0 corresponds to the null string. + } + + -- ROBLOX deviation: use postfix as a function + local function POSTFIX_INCREMENT() + local prevI = i + i += 1 + return prevI + end + + local stringTableSize = operations[POSTFIX_INCREMENT()] + local stringTableEnd = i + stringTableSize + + while i < stringTableEnd do + -- ROBLOX deviation: don't binary encode strings, so store string directly rather than length + -- local nextLength = operations[POSTFIX_INCREMENT()] + -- local nextString = utfDecodeString(Array.slice(operations, i, i + nextLength)) + local nextString = operations[POSTFIX_INCREMENT()] + + table.insert(stringTable, nextString) + -- ROBLOX deviation: don't binary encode strings, so no need to move pointer + -- i = i + nextLength + end + + -- ROBLOX deviation: 1-indexing, use <= not < + while i <= #operations do + local operation = operations[i] + if operation == TREE_OPERATION_ADD then + local id = operations[i + 1] + local type_ = operations[i + 2] + + i += 3 + + if self._idToElement:has(id) then + error( + Error.new( + ("Cannot add node %s because a node with that id is already in the Store."):format(tostring(id)) + ) + ) + end + + local ownerID: number = 0 + local parentID: number = (nil :: any) :: number + + if type_ == ElementTypeRoot then + if __DEBUG__ then + debug_("Add", string.format("new root node %s", tostring(id))) + end + + local supportsProfiling = operations[i] > 0 + i += 1 + + local hasOwnerMetadata = operations[i] > 0 + + i += 1 + self._roots = Array.concat(self._roots, id) + + self._rootIDToRendererID:set(id, rendererID) + self._rootIDToCapabilities:set(id, { + hasOwnerMetadata = hasOwnerMetadata, + supportsProfiling = supportsProfiling, + }) + + self._idToElement:set(id, { + children = {}, + depth = -1, + displayName = nil, + hocDisplayNames = nil, + id = id, + isCollapsed = false, -- Never collapse roots; it would hide the entire tree. + key = nil, + ownerID = 0, + parentID = 0, + type = type_, + weight = 0, + }) + haveRootsChanged = true + else + parentID = (operations[i] :: any) :: number + i += 1 + ownerID = (operations[i] :: any) :: number + i += 1 + + local displayNameStringID = operations[i] + -- ROBLOX deviation: 1-indexed + local displayName = stringTable[displayNameStringID + 1] + + i += 1 + + local keyStringID = operations[i] + -- ROBLOX deviation: 1-indexed + local key = stringTable[keyStringID + 1] + + i += 1 + + if __DEBUG__ then + debug_( + "Add", + string.format( + "node %s (%s) as child of %s", + tostring(id), + displayName or "null", + tostring(parentID) + ) + ) + end + if not self._idToElement:has(parentID) then + error( + Error.new( + ("Cannot add child %s to parent %s because parent node was not found in the Store."):format( + tostring(id), + tostring(parentID) + ) + ) + ) + end + + local parentElement = (self._idToElement:get(parentID) :: any) :: Element + + table.insert(parentElement.children, id) + + local displayNameWithoutHOCs, hocDisplayNames = separateDisplayNameAndHOCs(displayName, type_) + + local element = { + children = {}, + depth = parentElement.depth + 1, + displayName = displayNameWithoutHOCs, + hocDisplayNames = hocDisplayNames, + id = id, + isCollapsed = self._collapseNodesByDefault, + key = key, + ownerID = ownerID, + parentID = parentElement.id, + type = type_, + weight = 1, + } + + self._idToElement:set(id, element) + table.insert(addedElementIDs, id) + self:_adjustParentTreeWeight(parentElement, 1) + + if ownerID > 0 then + local set = self._ownersMap:get(ownerID) + + -- ROBLOX FIXME Luau: needs type states to eliminate the manual cast + if set == nil then + set = Set.new() + self._ownersMap:set(ownerID, set :: Set) + end + + (set :: Set):add(id) + end + end + elseif operation == TREE_OPERATION_REMOVE then + local removeLength = operations[i + 1] + i += 2 + + -- ROBLOX deviation: 1-indexing use 1 not 0 + for removeIndex = 1, removeLength do + local id = (operations[i] :: any) :: number + + if not self._idToElement:has(id) then + error( + Error.new( + ("Cannot remove node %s because no matching node was found in the Store."):format( + tostring(id) + ) + ) + ) + end + i += 1 + + local element = (self._idToElement:get(id) :: any) :: Element + local children, ownerID, parentID, weight = + element.children, element.ownerID, element.parentID, element.weight + + if #children > 0 then + error(Error.new(string.format("Node %s was removed before its children.", tostring(id)))) + end + + self._idToElement:delete(id) + + local parentElement: Element? = nil + + if parentID == 0 then + if __DEBUG__ then + debug_("Remove", string.format("node %s root", tostring(id))) + end + + self._roots = Array.filter(self._roots, function(rootID) + return rootID ~= id + end) + + self._rootIDToRendererID:delete(id) + self._rootIDToCapabilities:delete(id) + + haveRootsChanged = true + else + if __DEBUG__ then + debug_("Remove", string.format("node %s from parent %s", tostring(id), tostring(parentID))) + end + + parentElement = (self._idToElement:get(parentID) :: any) :: Element + + if parentElement == nil then + error( + ("Cannot remove node %s from parent %s because no matching node was found in the Store."):format( + tostring(id), + tostring(parentID) + ) + ) + end + + local index = Array.indexOf((parentElement :: Element).children, id) + Array.splice((parentElement :: Element).children, index, 1) + end + + self:_adjustParentTreeWeight(parentElement, -weight) + removedElementIDs[id] = parentID + self._ownersMap:delete(id) + + if ownerID > 0 then + local set = self._ownersMap:get(ownerID) + -- ROBLOX FIXME Luau: without any cast below, we get: Types Set and nil cannot be compared with ~= because they do not have the same metatable + if set :: any ~= nil then + (set :: Set):delete(id) + end + end + end + elseif operation == TREE_OPERATION_REORDER_CHILDREN then + local id = (operations[i + 1] :: any) :: number + local numChildren = (operations[i + 2] :: any) :: number + + i += 3 + + if not self._idToElement:has(id) then + error( + Error.new( + ("Cannot reorder children for node %s because no matching node was found in the Store."):format( + tostring(id) + ) + ) + ) + end + + local element = (self._idToElement:get(id) :: any) :: Element + local children = element.children + + if #children ~= numChildren then + error("Children cannot be added or removed during a reorder operation.") + end + + -- ROBLOX deviation: 1-indexing use 1 not 0 + for j = 1, numChildren do + local childID = operations[i + j - 1] + + children[j] = childID + + if _G.__DEV__ then + local childElement: Element? = self._idToElement:get(childID) + + if childElement == nil or (childElement :: Element).parentID ~= id then + console.error("Children cannot be added or removed during a reorder operation.") + end + end + end + + i = i + numChildren + + if _G.__DEBUG__ then + debug_("Re-order", string.format("Node %s children %s", tostring(id), Array.join(children, ","))) + end + elseif operation == TREE_OPERATION_UPDATE_TREE_BASE_DURATION then + -- Base duration updates are only sent while profiling is in progress. + -- We can ignore them at this point. + -- The profiler UI uses them lazily in order to generate the tree. + i += 3 + else + error("Unsupported Bridge operation " .. tostring(operation)) + end + end + + self._revision += 1 + + if haveRootsChanged then + local prevSupportsProfiling = self._supportsProfiling + + self._hasOwnerMetadata = false + self._supportsProfiling = false + + for _, capabilities in self._rootIDToCapabilities do + local hasOwnerMetadata, supportsProfiling = capabilities.hasOwnerMetadata, capabilities.supportsProfiling + + if hasOwnerMetadata then + self._hasOwnerMetadata = true + end + if supportsProfiling then + self._supportsProfiling = true + end + end + self:emit("roots") + + if self._supportsProfiling ~= prevSupportsProfiling then + self:emit("supportsProfiling") + end + end + if __DEBUG__ then + -- ROBLOX deviation: inline require here to work around circular dependency + local devtoolsUtils = require(script.Parent.utils) :: any + local printStore = devtoolsUtils.printStore + console.log(printStore(self, true)) + console.groupEnd() + end + + self:emit("mutated", { addedElementIDs, removedElementIDs }) +end + +function Store:onBridgeOverrideComponentFilters(componentFilters: Array): () + self._componentFilters = componentFilters + + saveComponentFilters(componentFilters) +end + +function Store:onBridgeShutdown(): () + if __DEBUG__ then + debug_("onBridgeShutdown", "unsubscribing from Bridge") + end + + self._bridge:removeListener("operations", self.onBridgeOperations) + self._bridge:removeListener("shutdown", self.onBridgeShutdown) + self._bridge:removeListener("isBackendStorageAPISupported", self.onBridgeStorageSupported) +end + +function Store:onBridgeStorageSupported(isBackendStorageAPISupported: boolean): () + self._isBackendStorageAPISupported = isBackendStorageAPISupported + self:emit("supportsReloadAndProfile") +end + +function Store:onBridgeUnsupportedRendererVersion(): () + self._unsupportedRendererVersionDetected = true + self:emit("unsupportedRendererVersionDetected") +end + +return Store diff --git a/packages/react-devtools-shared/src/devtools/types.lua b/packages/react-devtools-shared/src/devtools/types.lua new file mode 100644 index 00000000..8e993979 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/types.lua @@ -0,0 +1,228 @@ +--[[ + * Copyright (c) Roblox Corporation. All rights reserved. + * Licensed under the MIT License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://opensource.org/licenses/MIT + * + * 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. +]] + +--!strict +local Packages = script.Parent.Parent.Parent + +local LuauPolyfill = require(Packages.LuauPolyfill) +type Array = LuauPolyfill.Array +type Map = LuauPolyfill.Map +type Object = LuauPolyfill.Object +type Set = LuauPolyfill.Set + +local ComponentsTypes = require(script.Parent.Parent.devtools.views.Components.types) +type Element = ComponentsTypes.Element + +local Types = require(script.Parent.Parent.types) +type ComponentFilter = Types.ComponentFilter +type ElementType = Types.ElementType + +local EventEmitter = require(script.Parent.Parent.events) +type EventEmitter = EventEmitter.EventEmitter + +local Bridge = require(script.Parent.Parent.bridge) +type FrontendBridge = Bridge.FrontendBridge + +local backendTypes = require(script.Parent.Parent.backend.types) +type ProfilingDataBackend = backendTypes.ProfilingDataBackend + +local profilerTypes = require(script.Parent.views.Profiler.types) +type CommitDataFrontend = profilerTypes.CommitDataFrontend +type ProfilingDataForRootFrontend = profilerTypes.ProfilingDataForRootFrontend +type ProfilingDataFrontend = profilerTypes.ProfilingDataFrontend +type SnapshotNode = profilerTypes.SnapshotNode + +export type Capabilities = { hasOwnerMetadata: boolean, supportsProfiling: boolean } + +export type Store = EventEmitter<{ + collapseNodesByDefault: Array, + componentFilters: Array, + mutated: Array, -- ROBLOX deviation: can't express jagged array types in Luau + recordChangeDescriptions: Array, + roots: Array, + supportsNativeStyleEditor: Array, + supportsProfiling: Array, + supportsReloadAndProfile: Array, + unsupportedRendererVersionDetected: Array, +}> & { + _bridge: FrontendBridge, + + -- Should new nodes be collapsed by default when added to the tree? + _collapseNodesByDefault: boolean, + + _componentFilters: Array, + + -- At least one of the injected renderers contains (DEV only) owner metadata. + _hasOwnerMetadata: boolean, + + -- Map of ID to (mutable) Element. + -- Elements are mutated to avoid excessive cloning during tree updates. + -- The InspectedElementContext also relies on this mutability for its WeakMap usage. + _idToElement: Map, + + -- Should the React Native style editor panel be shown? + _isNativeStyleEditorSupported: boolean, + + -- Can the backend use the Storage API (e.g. localStorage)? + -- If not, features like reload-and-profile will not work correctly and must be disabled. + _isBackendStorageAPISupported: boolean, + + _nativeStyleEditorValidAttributes: Array | nil, + + -- Map of element (id) to the set of elements (ids) it owns. + -- This map enables getOwnersListForElement() to avoid traversing the entire tree. + _ownersMap: Map>, + + _profilerStore: ProfilerStore, + + _recordChangeDescriptions: boolean, + + -- Incremented each time the store is mutated. + -- This enables a passive effect to detect a mutation between render and commit phase. + _revision: number, + + -- This Array must be treated as immutable! + -- Passive effects will check it for changes between render and mount. + _roots: Array, + + _rootIDToCapabilities: Map, + + -- Renderer ID is needed to support inspection fiber props, state, and hooks. + _rootIDToRendererID: Map, + + -- These options may be initially set by a confiugraiton option when constructing the Store. + -- In the case of "supportsProfiling", the option may be updated based on the injected renderers. + _supportsNativeInspection: boolean, + _supportsProfiling: boolean, + _supportsReloadAndProfile: boolean, + _supportsTraceUpdates: boolean, + + _unsupportedRendererVersionDetected: boolean, + + -- Total number of visible elements (within all roots). + -- Used for windowing purposes. + _weightAcrossRoots: number, + assertExpectedRootMapSizes: (self: Store) -> (), + assertMapSizeMatchesRootCount: (self: Store, map: Map, mapName: string) -> (), + getCollapseNodesByDefault: (self: Store) -> boolean, + setCollapseNodesByDefault: (self: Store, boolean) -> (), + getComponentFilters: (self: Store) -> Array, + setComponentFilters: (self: Store, Array) -> (), + getHasOwnerMetadata: (self: Store) -> boolean, + getNativeStyleEditorValidAttributes: (self: Store) -> Array | nil, + getNumElements: (self: Store) -> number, + getProfilerStore: (self: Store) -> ProfilerStore, + getRecordChangeDescriptions: (self: Store) -> boolean, + setRecordChangeDescriptions: (self: Store, value: boolean) -> (), + getRevision: (self: Store) -> number, + getRootIDToRendererID: (self: Store) -> Map, + getRoots: (self: Store) -> Array, + getSupportsNativeInspection: (self: Store) -> boolean, + getSupportsNativeStyleEditor: (self: Store) -> boolean, + getSupportsProfiling: (self: Store) -> boolean, + getSupportsReloadAndProfile: (self: Store) -> boolean, + getSupportsTraceUpdates: (self: Store) -> boolean, + getUnsupportedRendererVersionDetected: (self: Store) -> boolean, + containsElement: (self: Store, id: number) -> boolean, + getElementAtIndex: (self: Store, index: number) -> Element | nil, + getElementIDAtIndex: (self: Store, index: number) -> number | nil, + getElementByID: (self: Store, id: number) -> Element | nil, + getIndexOfElementID: (self: Store, id: number) -> number | nil, + getOwnersListForElement: (self: Store, ownerID: number) -> Array, + getRendererIDForElement: (self: Store, id: number) -> number | nil, + getRootIDForElement: (self: Store, id: number) -> number | nil, + isInsideCollapsedSubTree: (self: Store, id: number) -> boolean, + toggleIsCollapsed: (self: Store, id: number, isCollapsed: boolean) -> (), + _adjustParentTreeWeight: (self: Store, parentElement: Element | nil, weightDelta: number) -> (), + onBridgeNativeStyleEditorSupported: ( + self: Store, + options: { + isSupported: boolean, + validAttributes: Array, + } + ) -> (), + onBridgeOperations: (self: Store, operations: Array) -> (), + onBridgeOverrideComponentFilters: (self: Store, componentFilters: Array) -> (), + onBridgeShutdown: (self: Store) -> (), + onBridgeStorageSupported: (self: Store, isBackendStorageAPISupported: boolean) -> (), + onBridgeUnsupportedRendererVersion: (self: Store) -> (), +} + +export type ProfilingCache = { + _fiberCommits: Map>, + _profilerStore: ProfilerStore, + getCommitTree: any, + getFiberCommits: any, + getFlamegraphChartData: any, + getInteractionsChartData: any, + getRankedChartData: any, + invalidate: (self: ProfilingCache) -> (), +} + +export type ProfilerStore = EventEmitter<{ + isProcessingData: any, --[[ ROBLOX TODO: Unhandled node for type: TupleTypeAnnotation ]] --[[ [] ]] + isProfiling: any, --[[ ROBLOX TODO: Unhandled node for type: TupleTypeAnnotation ]] --[[ [] ]] + profilingData: any, --[[ ROBLOX TODO: Unhandled node for type: TupleTypeAnnotation ]] --[[ [] ]] +}> & { + _bridge: FrontendBridge, -- Suspense cache for lazily calculating derived profiling data. + _cache: ProfilingCache, -- Temporary store of profiling data from the backend renderer(s). + -- This data will be converted to the ProfilingDataFrontend format after being collected from all renderers. + _dataBackends: Array, -- Data from the most recently completed profiling session, + -- or data that has been imported from a previously exported session. + -- This object contains all necessary data to drive the Profiler UI interface, + -- even though some of it is lazily parsed/derived via the ProfilingCache. + _dataFrontend: ProfilingDataFrontend | nil, -- Snapshot of all attached renderer IDs. + -- Once profiling is finished, this snapshot will be used to query renderers for profiling data. + -- + -- This map is initialized when profiling starts and updated when a new root is added while profiling; + -- Upon completion, it is converted into the exportable ProfilingDataFrontend format. + _initialRendererIDs: Set, -- Snapshot of the state of the main Store (including all roots) when profiling started. + -- Once profiling is finished, this snapshot can be used along with "operations" messages emitted during profiling, + -- to reconstruct the state of each root for each commit. + -- It's okay to use a single root to store this information because node IDs are unique across all roots. + -- + -- This map is initialized when profiling starts and updated when a new root is added while profiling; + -- Upon completion, it is converted into the exportable ProfilingDataFrontend format. + _initialSnapshotsByRootID: Map>, -- Map of root (id) to a list of tree mutation that occur during profiling. + -- Once profiling is finished, these mutations can be used, along with the initial tree snapshots, + -- to reconstruct the state of each root for each commit. + -- + -- This map is only updated while profiling is in progress; + -- Upon completion, it is converted into the exportable ProfilingDataFrontend format. + _inProgressOperationsByRootID: Map>>, -- The backend is currently profiling. + -- When profiling is in progress, operations are stored so that we can later reconstruct past commit trees. + _isProfiling: boolean, -- Tracks whether a specific renderer logged any profiling data during the most recent session. + _rendererIDsThatReportedProfilingData: Set, -- After profiling, data is requested from each attached renderer using this queue. + -- So long as this queue is not empty, the store is retrieving and processing profiling data from the backend. + _rendererQueue: Set, + _store: Store, + getCommitData: (self: ProfilerStore, rootID: number, commitIndex: number) -> CommitDataFrontend, + getDataForRoot: (self: ProfilerStore, rootID: number) -> ProfilingDataForRootFrontend, -- Profiling data has been recorded for at least one root. + didRecordCommits: (self: ProfilerStore) -> boolean, + isProcessingData: (self: ProfilerStore) -> boolean, + isProfiling: (self: ProfilerStore) -> boolean, + profilingCache: (self: ProfilerStore) -> ProfilingCache, + profilingData: (self: ProfilerStore, value: ProfilingDataFrontend?) -> ...ProfilingDataFrontend?, + clear: (self: ProfilerStore) -> (), + startProfiling: (self: ProfilerStore) -> (), + stopProfiling: (self: ProfilerStore) -> (), + _takeProfilingSnapshotRecursive: any, + onBridgeOperations: (self: ProfilerStore, operations: Array) -> (), + onBridgeProfilingData: (self: ProfilerStore, dataBackend: ProfilingDataBackend) -> (), + onBridgeShutdown: (self: ProfilerStore) -> (), + onProfilingStatus: (self: ProfilerStore, isProfiling: boolean) -> (), +} + +return true diff --git a/packages/react-devtools-shared/src/devtools/utils.lua b/packages/react-devtools-shared/src/devtools/utils.lua new file mode 100644 index 00000000..98542ed8 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/utils.lua @@ -0,0 +1,150 @@ +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/devtools/utils.js +--[[* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. +]] + +local Packages = script.Parent.Parent.Parent + +local LuauPolyfill = require(Packages.LuauPolyfill) +local Array = LuauPolyfill.Array +type Array = LuauPolyfill.Array +-- ROBLOX deviation: Use HttpService for JSON +local JSON = game:GetService("HttpService") + +local exports = {} + +local ViewsComponentsTypes = require(script.Parent.views.Components.types) +type Element = ViewsComponentsTypes.Element +local devtoolsTypes = require(script.Parent.types) +type Store = devtoolsTypes.Store + +exports.printElement = function(element: Element, includeWeight: boolean?) + includeWeight = includeWeight or false + local prefix = " " + + if #element.children > 0 then + prefix = if element.isCollapsed then "▸" else "▾" + end + + local key = "" + + if element.key ~= nil and element.key ~= "" then + key = string.format(' key="%s"', tostring(element.key)) + end + + local hocDisplayNames = nil + + if element.hocDisplayNames ~= nil then + hocDisplayNames = table.clone(element.hocDisplayNames) + end + + local hocs = if hocDisplayNames == nil then "" else string.format(" [%s]", table.concat(hocDisplayNames, "][")) + local suffix = "" + + if includeWeight then + suffix = string.format(" (%s)", if element.isCollapsed then "1" else tostring(element.weight)) + end + return string.format( + "%s%s <%s%s>%s%s", + (" "):rep(element.depth + 1), + prefix, + element.displayName or "null", + key, + hocs, + suffix + ) +end + +exports.printOwnersList = function(elements: Array, includeWeight: boolean) + includeWeight = includeWeight or false + return table.concat( + Array.map(elements, function(element) + return exports.printElement(element, includeWeight) + end), + "\n" + ) +end + +exports.printStore = function(store: Store, includeWeight: boolean?) + includeWeight = includeWeight or false + local snapshotLines: Array = {} + local rootWeight = 0 + + Array.forEach(store:getRoots(), function(rootID) + local weight = ((store:getElementByID(rootID) :: any) :: Element).weight + + table.insert(snapshotLines, "[root]" .. (if includeWeight then string.format(" (%d)", weight) else "")) + for i = rootWeight, rootWeight + weight - 1 do + local element: Element? = store:getElementAtIndex(i) + + if element == nil then + error(string.format("Could not find element at index %d", i)) + end + + table.insert(snapshotLines, exports.printElement(element :: Element, includeWeight :: boolean)) + end + rootWeight += weight + end) + + -- Make sure the pretty-printed test align with the Store's reported number of total rows. + if rootWeight ~= store:getNumElements() then + error( + ("Inconsistent Store state. Individual root weights (%s) do not match total weight (%s)"):format( + tostring(rootWeight), + tostring(store:getNumElements()) + ) + ) + end + + -- If roots have been unmounted, verify that they've been removed from maps. + -- This helps ensure the Store doesn't leak memory. + store:assertExpectedRootMapSizes() + + return table.concat(snapshotLines, "\n") +end + +-- We use JSON.parse to parse string values +-- e.g. 'foo' is not valid JSON but it is a valid string +-- so this method replaces e.g. 'foo' with "foo" +exports.sanitizeForParse = function(value) + if typeof(value) == "string" then + if #value >= 2 and string.sub(value, 1, 1) == "'" and string.sub(value, #value) == "'" then + return '"' .. string.sub(value, 1, #value - 2) .. '"' + end + end + return value +end + +exports.smartParse = function(value): number? + if value == "Infinity" then + return math.huge + elseif value == "NaN" then + -- ROBLOX deviation: no NaN + return 0 + elseif value == "undefined" then + return nil + else + return JSON:JSONDecode(exports.sanitizeForParse(value)) + end +end + +exports.smartStringify = function(value) + if typeof(value) == "number" then + -- ROBLOX deviation: these numbers don't exist + -- if Number.isNaN(value) then + -- return'NaN' + -- elseif not Number.isFinite(value) then + -- return'Infinity' + -- end + elseif value == nil then + return "undefined" + end + + return JSON:JSONEncode(value) +end + +return exports diff --git a/packages/react-devtools-shared/src/devtools/views/Components/types.lua b/packages/react-devtools-shared/src/devtools/views/Components/types.lua new file mode 100644 index 00000000..a979aecd --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Components/types.lua @@ -0,0 +1,122 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/devtools/views/Components/types.js +-- /** +-- * Copyright (c) Facebook, Inc. and its affiliates. +-- * +-- * This source code is licensed under the MIT license found in the +-- * LICENSE file in the root directory of this source tree. +-- * +-- * @flow +-- */ + +local Packages = script.Parent.Parent.Parent.Parent.Parent +local LuauPolyfill = require(Packages.LuauPolyfill) +type Array = LuauPolyfill.Array +type Object = LuauPolyfill.Object + +local ReactShared = require(Packages.Shared) +type Source = ReactShared.Source +local Hydration = require(script.Parent.Parent.Parent.Parent.hydration) +type Dehydrated = Hydration.Dehydrated +type Unserializable = Hydration.Unserializable + +local ReactDevtoolsSharedTypes = require(script.Parent.Parent.Parent.Parent.types) +type ElementType = ReactDevtoolsSharedTypes.ElementType + +-- Each element on the frontend corresponds to a Fiber on the backend. +-- Some of its information (e.g. id, type, displayName) come from the backend. +-- Other bits (e.g. weight and depth) are computed on the frontend for windowing and display purposes. +-- Elements are updated on a push basis– meaning the backend pushes updates to the frontend when needed. +export type Element = { + id: number, + parentID: number, + children: Array, + type: ElementType, + displayName: string | nil, + key: number | string | nil, + + hocDisplayNames: nil | Array, + + -- Should the elements children be visible in the tree? + isCollapsed: boolean, + + -- Owner (if available) + ownerID: number, + + -- How many levels deep within the tree is this element? + -- This determines how much indentation (left padding) should be used in the Elements tree. + depth: number, + + -- How many nodes (including itself) are below this Element within the tree. + -- This property is used to quickly determine the total number of Elements, + -- and the Element at any given index (for windowing purposes). + weight: number, +} + +export type Owner = { + displayName: string | nil, + id: number, + hocDisplayNames: Array | nil, + type: ElementType, +} + +export type OwnersList = { id: number, owners: Array | nil } + +export type InspectedElement = { + id: number, + + -- Does the current renderer support editable hooks and function props? + canEditHooks: boolean, + canEditFunctionProps: boolean, + + -- Does the current renderer support advanced editing interface? + canEditHooksAndDeletePaths: boolean, + canEditHooksAndRenamePaths: boolean, + canEditFunctionPropsDeletePaths: boolean, + canEditFunctionPropsRenamePaths: boolean, + + -- Is this Suspense, and can its value be overridden now? + canToggleSuspense: boolean, + + -- Can view component source location. + canViewSource: boolean, + + -- Does the component have legacy context attached to it. + hasLegacyContext: boolean, + + -- Inspectable properties. + context: Object | nil, + hooks: Object | nil, + props: Object | nil, + state: Object | nil, + key: number | string | nil, + + -- List of owners + owners: Array | nil, + + -- Location of component in source code. + source: Source | nil, + + type: ElementType, + + -- Meta information about the root this element belongs to. + rootType: string | nil, + + -- Meta information about the renderer that created this element. + rendererPackageName: string | nil, + rendererVersion: string | nil, +} + +-- TODO: Add profiling type + +export type DehydratedData = { + cleaned: Array>, + data: string + | Dehydrated + | Unserializable + | Array + | Array + | { [string]: string | Dehydrated | Unserializable }, + unserializable: Array>, +} + +return {} diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.lua b/packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.lua new file mode 100644 index 00000000..e9743d01 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.lua @@ -0,0 +1,326 @@ +--!strict +--[[* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + ]] +local Packages = script.Parent.Parent.Parent.Parent.Parent +local LuauPolyfill = require(Packages.LuauPolyfill) +local Array = LuauPolyfill.Array +local Map = LuauPolyfill.Map +local console = LuauPolyfill.console + +type Map = LuauPolyfill.Map +type Array = LuauPolyfill.Array + +local exports = {} +--[[* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + ]] +local constantsModule = require(script.Parent.Parent.Parent.Parent.constants) +local __DEBUG__ = constantsModule.__DEBUG__ +local TREE_OPERATION_ADD = constantsModule.TREE_OPERATION_ADD +local TREE_OPERATION_REMOVE = constantsModule.TREE_OPERATION_REMOVE +local TREE_OPERATION_REORDER_CHILDREN = constantsModule.TREE_OPERATION_REORDER_CHILDREN +local TREE_OPERATION_UPDATE_TREE_BASE_DURATION = constantsModule.TREE_OPERATION_UPDATE_TREE_BASE_DURATION + +local devtoolsTypes = require(script.Parent.Parent.Parent.types) +type ProfilerStore = devtoolsTypes.ProfilerStore + +local ElementTypeRoot = require(script.Parent.Parent.Parent.Parent.types).ElementTypeRoot +local typesModule = require(script.Parent.Parent.Parent.Parent.types) +type ElementType = typesModule.ElementType + +local Profiler_typesModule = require(script.Parent.types) +type CommitTree = Profiler_typesModule.CommitTree +type CommitTreeNode = Profiler_typesModule.CommitTreeNode +type ProfilingDataForRootFrontend = Profiler_typesModule.ProfilingDataForRootFrontend +type ProfilingDataFrontend = Profiler_typesModule.ProfilingDataFrontend + +local function debug_(methodName, ...: any) + if __DEBUG__ then + print("[CommitTreeBuilder]", methodName, ...) + end +end + +local function __printTree(commitTree: CommitTree) + if __DEBUG__ then + local nodes, rootID = commitTree.nodes, commitTree.rootID + console.group("__printTree()") + local queue = { rootID, 0 } + -- ROBLOX TODO Luau? if length check > 0, remove() nil-ability could be removed + while #queue > 0 do + local id = table.remove(queue, 1) :: number + local depth = table.remove(queue, 1) :: number + local node = nodes:get(id) + -- ROBLOX FIXME Luau: need to understand error() narrows node nil-ability + if node == nil then + error(string.format('Could not find node with id "%s" in commit tree', tostring(id))) + end + console.log( + string.format( + "%s%s:%s %s (%s)", + string.rep("\u{2022}", depth), + tostring((node :: CommitTreeNode).id), + tostring((node :: CommitTreeNode).displayName or ""), + if (node :: CommitTreeNode).key + then string.format('key:"%s"', tostring((node :: CommitTreeNode).key)) + else "", + tostring((node :: CommitTreeNode).treeBaseDuration) + ) + ) + Array.forEach((node :: CommitTreeNode).children, function(childID) + Array.concat(queue, { childID, depth + 1 }) + end) + end + console.groupEnd() + end +end + +local function updateTree(commitTree: CommitTree, operations: Array): CommitTree + -- Clone the original tree so edits don't affect it. + local nodes = Map.new(commitTree.nodes) + + -- Clone nodes before mutating them so edits don't affect the original. + local function getClonedNode(id: number): CommitTreeNode + local clonedNode = table.clone((nodes:get(id) :: any) :: CommitTreeNode) + nodes:set(id, clonedNode) + return clonedNode + end + + local i = 3 -- Skip rendererID and currentRootID + local function POSTFIX_INCREMENT() + local x = i + i += 1 + return x + end + + local id: number = (nil :: any) :: number -- Reassemble the string table. + local stringTable: Array = { + -- ROBLOX deviation: element 1 corresponds to empty string, this is why key is "" instead of nil in snapshots + "", -- ID = 0 corresponds to the null string. + } + + local stringTableSize = operations[POSTFIX_INCREMENT()] + local stringTableEnd = i + stringTableSize + + while i < stringTableEnd do + -- ROBLOX deviation: don't binary encode strings, so store string directly rather than length + -- local nextLength = operations[POSTFIX_INCREMENT()] + -- local nextString = utfDecodeString(Array.slice(operations, i, i + nextLength)) + local nextString = operations[POSTFIX_INCREMENT()] + + table.insert(stringTable, nextString) + -- ROBLOX deviation: don't binary encode strings, so no need to move pointer + -- i = i + nextLength + end + + while i <= #operations do + local operation = operations[POSTFIX_INCREMENT()] + + if operation == TREE_OPERATION_ADD then + id = operations[POSTFIX_INCREMENT()] + local type_ = (operations[POSTFIX_INCREMENT()] :: any) :: ElementType + if nodes:has(id) then + error("Commit tree already contains fiber " .. tostring(id) .. ". This is a bug in React DevTools.") + end + if type_ == ElementTypeRoot then + i += 2 -- supportsProfiling flag and hasOwnerMetadata flag + if __DEBUG__ then + debug_("Add", ("new root fiber %s"):format(tostring(id))) + end + local node: CommitTreeNode = { + children = {}, + displayName = nil, + hocDisplayNames = nil, + id = id, + key = nil, + parentID = 0, + treeBaseDuration = 0, -- This will be updated by a subsequent operation + type = type_, + } + nodes:set(id, node) + else + local parentID = operations[POSTFIX_INCREMENT()] + i += 1 -- skip ownerID + local displayNameStringID = operations[POSTFIX_INCREMENT()] + local displayName = stringTable[displayNameStringID + 1] + + local keyStringID = operations[POSTFIX_INCREMENT()] + local key = stringTable[keyStringID + 1] -- 1 indexed stringtable + + if __DEBUG__ then + debug_( + "Add", + ("fiber %s (%s) as child of %s"):format( + tostring(id), + tostring(displayName or "null"), + tostring(parentID) + ) + ) + end + local parentNode = getClonedNode(parentID) + parentNode.children = Array.concat(parentNode.children, id) + local node: CommitTreeNode = { + children = {}, + displayName = displayName, + hocDisplayNames = nil, + id = id, + key = key, + parentID = parentID, + treeBaseDuration = 0, -- This will be updated by a subsequent operation + type = type_, + } + nodes:set(id, node) + end + elseif operation == TREE_OPERATION_REMOVE then + local removeLength = operations[POSTFIX_INCREMENT()] + for _ = 1, removeLength do + id = operations[POSTFIX_INCREMENT()] + if not nodes:has(id) then + error("Commit tree does not contain fiber " .. tostring(id) .. ". This is a bug in React DevTools.") + end + local node = getClonedNode(id) + local parentID = node.parentID + nodes:delete(id) + if not nodes:has(parentID) then + -- No-op + else + local parentNode = getClonedNode(parentID) + if __DEBUG__ then + debug_("Remove", ("fiber %s from parent %s"):format(tostring(id), tostring(parentID))) + end + parentNode.children = Array.filter(parentNode.children, function(childID) + return childID ~= id + end) + end + end + elseif operation == TREE_OPERATION_REORDER_CHILDREN then + id = operations[POSTFIX_INCREMENT()] + local numChildren = operations[POSTFIX_INCREMENT()] + local children = (Array.slice(operations, i, i + numChildren) :: any) :: Array + i += numChildren + if __DEBUG__ then + debug_("Re-order", ("fiber %s children %s"):format(tostring(id), tostring(Array.join(children, ",")))) + end + local node = getClonedNode(id) + -- ROBLOX FIXME Luau: this cast shouldn't be necessary + node.children = Array.from(children) :: Array + elseif operation == TREE_OPERATION_UPDATE_TREE_BASE_DURATION then + id = operations[POSTFIX_INCREMENT()] + local node = getClonedNode(id) + node.treeBaseDuration = operations[POSTFIX_INCREMENT()] / 1000 -- Convert microseconds back to milliseconds; + if __DEBUG__ then + debug_( + "Update", + ("fiber %s treeBaseDuration to %s"):format(tostring(id), tostring(node.treeBaseDuration)) + ) + end + else + error(string.format("Unsupported Bridge operation %s at operation index %d", tostring(operation), i)) + end + end + return { nodes = nodes, rootID = commitTree.rootID } +end + +local function recursivelyInitializeTree( + id: number, + parentID: number, + nodes: Map, + dataForRoot: ProfilingDataForRootFrontend +): () + local node = dataForRoot.snapshots:get(id) + if node ~= nil then + nodes:set(id, { + id = id, + children = node.children, + displayName = node.displayName, + hocDisplayNames = node.hocDisplayNames, + key = node.key, + parentID = parentID, + treeBaseDuration = (dataForRoot.initialTreeBaseDurations:get(id) :: any) :: number, + type = node.type, + }) + for _, childID in node.children do + recursivelyInitializeTree(childID, id, nodes, dataForRoot) + end + end +end + +local rootToCommitTreeMap: Map> = Map.new() +local function getCommitTree(ref: { + commitIndex: number, + profilerStore: ProfilerStore, + rootID: number, +}): CommitTree + local commitIndex, profilerStore, rootID = ref.commitIndex, ref.profilerStore, ref.rootID + if not rootToCommitTreeMap:has(rootID) then + rootToCommitTreeMap:set(rootID, {}) + end + local commitTrees = (rootToCommitTreeMap:get(rootID) :: any) :: Array + if commitIndex <= #commitTrees then + return commitTrees[commitIndex] + end + local profilingData = profilerStore:profilingData() + -- ROBLOX FIXME Luau: need to understand error() means profilingData gets nil-ability stripped. needs type states. + if profilingData == nil then + error("No profiling data available") + end + local dataForRoot = (profilingData :: ProfilingDataFrontend).dataForRoots:get(rootID) + -- ROBLOX FIXME Luau: need to understand error() means profilingData gets nil-ability stripped. needs type states. + if dataForRoot == nil then + error(string.format('Could not find profiling data for root "%s"', tostring(rootID))) + end + local operations = (dataForRoot :: ProfilingDataForRootFrontend).operations -- Commits are generated sequentially and cached. + -- If this is the very first commit, start with the cached snapshot and apply the first mutation. + -- Otherwise load (or generate) the previous commit and append a mutation to it. + if commitIndex == 1 then + local nodes = Map.new() -- Construct the initial tree. + recursivelyInitializeTree(rootID, 0, nodes, dataForRoot :: ProfilingDataForRootFrontend) -- Mutate the tree + if operations ~= nil and commitIndex <= #operations then + local commitTree = updateTree({ nodes = nodes, rootID = rootID }, operations[commitIndex]) + if __DEBUG__ then + __printTree(commitTree) + end + table.insert(commitTrees, commitTree) + return commitTree + end + else + local previousCommitTree = getCommitTree({ + commitIndex = commitIndex - 1, + profilerStore = profilerStore, + rootID = rootID, + }) + if operations ~= nil and commitIndex <= #operations then + local commitTree = updateTree(previousCommitTree, operations[commitIndex]) + if __DEBUG__ then + __printTree(commitTree) + end + table.insert(commitTrees, commitTree) + return commitTree + end + end + error( + string.format( + 'getCommitTree(): Unable to reconstruct tree for root "%s" and commit %s', + tostring(rootID), + tostring(commitIndex) + ) + ) +end +exports.getCommitTree = getCommitTree + +local function invalidateCommitTrees(): any? + return rootToCommitTreeMap:clear() +end +exports.invalidateCommitTrees = invalidateCommitTrees -- DEBUG + +return exports diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/FlamegraphChartBuilder.lua b/packages/react-devtools-shared/src/devtools/views/Profiler/FlamegraphChartBuilder.lua new file mode 100644 index 00000000..cc7430e3 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/FlamegraphChartBuilder.lua @@ -0,0 +1,199 @@ +--!strict +--[[* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + ]] +local Packages = script.Parent.Parent.Parent.Parent.Parent +local LuauPolyfill = require(Packages.LuauPolyfill) +local Boolean = LuauPolyfill.Boolean +local Map = LuauPolyfill.Map +local Set = LuauPolyfill.Set + +type Map = LuauPolyfill.Map +type Array = LuauPolyfill.Array +type Set = LuauPolyfill.Set + +local exports = {} + +local devtoolsTypes = require(script.Parent.Parent.Parent.types) +type ProfilerStore = devtoolsTypes.ProfilerStore + +local formatDuration = require(script.Parent.utils).formatDuration +local typesModule = require(script.Parent.types) +type CommitTree = typesModule.CommitTree +type CommitTreeNode = typesModule.CommitTreeNode + +export type ChartNode = { + actualDuration: number, + didRender: boolean, + id: number, + label: string, + name: string, + offset: number, + selfDuration: number, + treeBaseDuration: number, +} +export type ChartData = { + baseDuration: number, + depth: number, + idToDepthMap: Map, + maxSelfDuration: number, + renderPathNodes: Set, + rows: Array>, +} +local cachedChartData: Map = Map.new() +local function getChartData(ref: { + commitIndex: number, + commitTree: CommitTree, + profilerStore: ProfilerStore, + rootID: number, +}): ChartData + local commitIndex, commitTree, profilerStore, rootID = + ref.commitIndex, ref.commitTree, ref.profilerStore, ref.rootID + local commitDatum = profilerStore:getCommitData(rootID, commitIndex) + local fiberActualDurations, fiberSelfDurations = commitDatum.fiberActualDurations, commitDatum.fiberSelfDurations + local nodes = commitTree.nodes + local chartDataKey = ("%s-%s"):format(tostring(rootID), tostring(commitIndex)) + if cachedChartData:has(chartDataKey) then + return (cachedChartData:get(chartDataKey) :: any) :: ChartData + end + local idToDepthMap: Map = Map.new() + local renderPathNodes: Set = Set.new() + local rows: Array> = {} + local maxDepth = 0 + local maxSelfDuration = 0 + + -- Generate flame graph structure using tree base durations. + local function walkTree(id: number, rightOffset: number, currentDepth: number) + idToDepthMap:set(id, currentDepth) + + local node = nodes:get(id) + if node == nil then + error(string.format('Could not find node with id "%s" in commit tree', tostring(id))) + end + -- ROBLOX FIXME Luau: Luau doesn't understand error() narrows, needs type states + local children, displayName, hocDisplayNames, key, treeBaseDuration = + (node :: CommitTreeNode).children, + (node :: CommitTreeNode).displayName, + (node :: CommitTreeNode).hocDisplayNames, + (node :: CommitTreeNode).key, + (node :: CommitTreeNode).treeBaseDuration + + local actualDuration = fiberActualDurations:get(id) or 0 + local selfDuration = fiberSelfDurations:get(id) or 0 + local didRender = fiberActualDurations:has(id) + + local name = displayName or "Anonymous" + local maybeKey = if Boolean.toJSBoolean(key) then (' key="%s"'):format(tostring(key)) else "" + + local maybeBadge = "" + if hocDisplayNames ~= nil and #hocDisplayNames > 0 then + maybeBadge = string.format(" (%s)", tostring(hocDisplayNames[1])) + end + + local label = string.format( + "%s%s%s%s", + tostring(name), + tostring(maybeBadge), + tostring(maybeKey), + if didRender + then string.format( + " (%sms of %sms)", + tostring(formatDuration(selfDuration)), + tostring(formatDuration(actualDuration)) + ) + else "" + ) + + maxDepth = math.max(maxDepth, currentDepth) + maxSelfDuration = math.max(maxSelfDuration, selfDuration) + local chartNode: ChartNode = { + actualDuration = actualDuration, + didRender = didRender, + id = id, + label = label, + name = name, + offset = rightOffset - treeBaseDuration, + selfDuration = selfDuration, + treeBaseDuration = treeBaseDuration, + } + if currentDepth > #rows then + table.insert(rows, { chartNode }) + else + table.insert(rows[currentDepth - 1], chartNode) + end + do + local i = #children + while i >= 1 do + local childID = children[i] + local childChartNode = walkTree(childID, rightOffset, currentDepth) + rightOffset -= childChartNode.treeBaseDuration + i -= 1 + end + end + return chartNode + end + local baseDuration = 0 -- Special case to handle unmounted roots. + if nodes.size > 0 then + -- Skip over the root; we don't want to show it in the flamegraph. + local root = nodes:get(rootID) + if root == nil then + error(string.format('Could not find root node with id "%s" in commit tree', tostring(rootID))) + end + -- Don't assume a single root. + -- Component filters or Fragments might lead to multiple "roots" in a flame graph. + do + -- ROBLOX FIXME Luau: Luau doesn't understand error() narrows, needs type states + local i = #(root :: CommitTreeNode).children + while i >= 1 do + local id = (root :: CommitTreeNode).children[i] + local node = nodes:get(id) + if node == nil then + error(string.format('Could not find node with id "%s" in commit tree', tostring(id))) + end + -- ROBLOX FIXME Luau: Luau doesn't understand error() narrows, needs type states + baseDuration += (node :: CommitTreeNode).treeBaseDuration + -- ROBLOX deviation START: walkTree does table.insert(tbl, currentDepth - 1), so the parameter here needs to be a valid index with after substracting 1 at the start + walkTree(id, baseDuration, 2) + -- ROBLOX deviation END + i -= 1 + end + end + for id, duration in fiberActualDurations do + local node = nodes:get(id) + if node ~= nil then + local currentID = node.parentID + while currentID ~= 0 do + if renderPathNodes:has(currentID) then + -- We've already walked this path; we can skip it. + break + else + renderPathNodes:add(currentID) + end + node = nodes:get(currentID) + currentID = if node ~= nil then node.parentID else 0 + end + end + end + end + local chartData = { + baseDuration = baseDuration, + depth = maxDepth, + idToDepthMap = idToDepthMap, + maxSelfDuration = maxSelfDuration, + renderPathNodes = renderPathNodes, + rows = rows, + } + cachedChartData:set(chartDataKey, chartData) + return chartData +end +exports.getChartData = getChartData +local function invalidateChartData(): any + return cachedChartData:clear() +end +exports.invalidateChartData = invalidateChartData +return exports diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/InteractionsChartBuilder.lua b/packages/react-devtools-shared/src/devtools/views/Profiler/InteractionsChartBuilder.lua new file mode 100644 index 00000000..64d98110 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/InteractionsChartBuilder.lua @@ -0,0 +1,59 @@ +--!strict +--[[* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + ]] +local Packages = script.Parent.Parent.Parent.Parent.Parent +local LuauPolyfill = require(Packages.LuauPolyfill) +local Array = LuauPolyfill.Array +local Map = LuauPolyfill.Map + +type Map = LuauPolyfill.Map +type Array = LuauPolyfill.Array + +local exports = {} + +local devtoolsTypes = require(script.Parent.Parent.Parent.types) +type ProfilerStore = devtoolsTypes.ProfilerStore +local typesModule = require(script.Parent.types) +type Interaction = typesModule.Interaction +export type ChartData = { + interactions: Array, + lastInteractionTime: number, + maxCommitDuration: number, +} +local cachedChartData: Map = Map.new() +local function getChartData(ref: { profilerStore: ProfilerStore, rootID: number }): ChartData + local profilerStore, rootID = ref.profilerStore, ref.rootID + if cachedChartData:has(rootID) then + return (cachedChartData:get(rootID) :: any) :: ChartData + end + local dataForRoot = profilerStore:getDataForRoot(rootID) + if dataForRoot == nil then + error(string.format('Could not find profiling data for root "%s"', tostring(rootID))) + end + -- ROBLOX FIXME Luau: any cast necessary to work around: Property 'interactions' is not compatible. Type 'Array | Array | Array' could not be converted into 'Array' + local commitData, interactions: any = dataForRoot.commitData, dataForRoot.interactions + local lastInteractionTime = if #commitData > 0 then commitData[#commitData].timestamp else 0 + local maxCommitDuration = 0 + Array.forEach(commitData, function(commitDatum) + maxCommitDuration = math.max(maxCommitDuration, commitDatum.duration) + end) + local chartData = { + interactions = Array.from(interactions:values()) :: Array, + lastInteractionTime = lastInteractionTime, + maxCommitDuration = maxCommitDuration, + } + cachedChartData:set(rootID, chartData) + return chartData +end +exports.getChartData = getChartData +local function invalidateChartData(): any? + return cachedChartData:clear() +end +exports.invalidateChartData = invalidateChartData +return exports diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/RankedChartBuilder.lua b/packages/react-devtools-shared/src/devtools/views/Profiler/RankedChartBuilder.lua new file mode 100644 index 00000000..a16bb2d2 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/RankedChartBuilder.lua @@ -0,0 +1,101 @@ +--!strict +--[[* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + ]] +local Packages = script.Parent.Parent.Parent.Parent.Parent +local LuauPolyfill = require(Packages.LuauPolyfill) +local Array = LuauPolyfill.Array +local Boolean = LuauPolyfill.Boolean +local Map = LuauPolyfill.Map + +type Map = LuauPolyfill.Map +type Array = LuauPolyfill.Array +type Set = LuauPolyfill.Set + +local exports = {} + +local devtoolsTypes = require(script.Parent.Parent.Parent.types) +type ProfilerStore = devtoolsTypes.ProfilerStore + +local typesModule = require(script.Parent.Parent.Parent.Parent.types) +local ElementTypeForwardRef = typesModule.ElementTypeForwardRef +local ElementTypeMemo = typesModule.ElementTypeMemo +local formatDuration = require(script.Parent.utils).formatDuration +local Profiler_typesModule = require(script.Parent.types) +type CommitTree = Profiler_typesModule.CommitTree +type CommitTreeNode = Profiler_typesModule.CommitTreeNode + +export type ChartNode = { id: number, label: string, name: string, value: number } +export type ChartData = { maxValue: number, nodes: Array } +local cachedChartData: Map = Map.new() +local function getChartData(ref: { + commitIndex: number, + commitTree: CommitTree, + profilerStore: ProfilerStore, + rootID: number, +}): ChartData + local commitIndex, commitTree, profilerStore, rootID = + ref.commitIndex, ref.commitTree, ref.profilerStore, ref.rootID + local commitDatum = profilerStore:getCommitData(rootID, commitIndex) + local fiberActualDurations: Map, fiberSelfDurations: Map = + commitDatum.fiberActualDurations, commitDatum.fiberSelfDurations + local nodes = commitTree.nodes + local chartDataKey = ("%s-%s"):format(tostring(rootID), tostring(commitIndex)) + if cachedChartData:has(chartDataKey) then + return (cachedChartData:get(chartDataKey) :: any) :: ChartData + end + local maxSelfDuration = 0 + local chartNodes: Array = {} + -- ROBLOX deviation? this is a simple Map, but could .forEach() always be generalized into genealized for-in if the loop is 'simple'? + for id, actualDuration in fiberActualDurations do + local node = nodes:get(id) + if node == nil then + error(string.format('Could not find node with id "%s" in commit tree', tostring(id))) + end + -- ROBLOX FIXME Luau: need to understand that error() means `node` has nil-ability stripped + local displayName, key, parentID, type_ = + (node :: CommitTreeNode).displayName, + (node :: CommitTreeNode).key, + (node :: CommitTreeNode).parentID, + (node :: CommitTreeNode).type -- Don't show the root node in this chart. + if parentID == 0 then + continue + end + local selfDuration = fiberSelfDurations:get(id) or 0 + maxSelfDuration = math.max(maxSelfDuration, selfDuration) + local name = displayName or "Anonymous" + local maybeKey = if Boolean.toJSBoolean(key) then (' key="%s"'):format(tostring(key)) else "" + local maybeBadge = "" + if type_ == ElementTypeForwardRef then + maybeBadge = " (ForwardRef)" + elseif type_ == ElementTypeMemo then + maybeBadge = " (Memo)" + end + local label = ("%s%s%s (%sms)"):format( + tostring(name), + tostring(maybeBadge), + tostring(maybeKey), + tostring(formatDuration(selfDuration)) + ) + table.insert(chartNodes, { id = id, label = label, name = name, value = selfDuration }) + end + local chartData = { + maxValue = maxSelfDuration, + nodes = Array.sort(chartNodes, function(a, b) + return b.value - a.value + end), + } + cachedChartData:set(chartDataKey, chartData) + return chartData +end +exports.getChartData = getChartData +local function invalidateChartData(): any? + return cachedChartData:clear() +end +exports.invalidateChartData = invalidateChartData +return exports diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/types.lua b/packages/react-devtools-shared/src/devtools/views/Profiler/types.lua new file mode 100644 index 00000000..1a53a69b --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/types.lua @@ -0,0 +1,151 @@ +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/devtools/views/Profiler/types.js +-- /** +-- * Copyright (c) Facebook, Inc. and its affiliates. +-- * +-- * This source code is licensed under the MIT license found in the +-- * LICENSE file in the root directory of this source tree. +-- * +-- * @flow +-- */ + +local Packages = script.Parent.Parent.Parent.Parent.Parent +local LuauPolyfill = require(Packages.LuauPolyfill) +type Map = LuauPolyfill.Map +type Array = LuauPolyfill.Array +local exports = {} + +local ReactDevtoolsSharedTypes = require(script.Parent.Parent.Parent.Parent.types) + +type ElementType = ReactDevtoolsSharedTypes.ElementType + +export type CommitTreeNode = { + id: number, + children: Array, + displayName: string | nil, + hocDisplayNames: Array | nil, + key: number | string | nil, + parentID: number, + treeBaseDuration: number, + type: ElementType, +} + +export type CommitTree = { nodes: Map, rootID: number } + +export type Interaction = { id: number, name: string, timestamp: number } + +export type SnapshotNode = { + id: number, + children: Array, + displayName: string | nil, + hocDisplayNames: Array | nil, + key: number | string | nil, + type: ElementType, +} + +export type ChangeDescription = { + context: Array | boolean | nil, + didHooksChange: boolean, + isFirstMount: boolean, + props: Array | nil, + state: Array | nil, +} + +export type CommitDataFrontend = { + -- Map of Fiber (ID) to a description of what changed in this commit. + changeDescriptions: Map | nil, + + -- How long was this commit? + duration: number, + + -- Map of Fiber (ID) to actual duration for this commit; + -- Fibers that did not render will not have entries in this Map. + fiberActualDurations: Map, + + -- Map of Fiber (ID) to "self duration" for this commit; + -- Fibers that did not render will not have entries in this Map. + fiberSelfDurations: Map, + + -- Which interactions (IDs) were associated with this commit. + interactionIDs: Array, + + -- Priority level of the commit (if React provided this info) + priorityLevel: string | nil, + + -- When did this commit occur (relative to the start of profiling) + timestamp: number, +} + +export type ProfilingDataForRootFrontend = { + -- Timing, duration, and other metadata about each commit. + commitData: Array, + + -- Display name of the nearest descendant component (ideally a function or class component). + -- This value is used by the root selector UI. + displayName: string, + + -- Map of fiber id to (initial) tree base duration when Profiling session was started. + -- This info can be used along with commitOperations to reconstruct the tree for any commit. + initialTreeBaseDurations: Map, + + -- All interactions recorded (for this root) during the current session. + interactionCommits: Map>, + + -- All interactions recorded (for this root) during the current session. + interactions: Map, + + -- List of tree mutation that occur during profiling. + -- These mutations can be used along with initial snapshots to reconstruct the tree for any commit. + operations: Array>, + + -- Identifies the root this profiler data corresponds to. + rootID: number, + + -- Map of fiber id to node when the Profiling session was started. + -- This info can be used along with commitOperations to reconstruct the tree for any commit. + snapshots: Map, +} + +-- Combination of profiling data collected by the renderer interface (backend) and Store (frontend). +export type ProfilingDataFrontend = { + -- Profiling data per root. + dataForRoots: Map, + imported: boolean, +} + +export type CommitDataExport = { + -- ROBLOX TODO: how to express bracket syntax embedded in Array type? + -- changeDescriptions: Array<[number, ChangeDescription]> | nil, + changeDescriptions: Array> | nil, + duration: number, + -- Tuple of fiber ID and actual duration + fiberActualDurations: Array>, + -- Tuple of fiber ID and computed "self" duration + fiberSelfDurations: Array>, + interactionIDs: Array, + priorityLevel: string | nil, + timestamp: number, +} + +export type ProfilingDataForRootExport = { + commitData: Array, + displayName: string, + -- Tuple of Fiber ID and base duration + initialTreeBaseDurations: Array>, + -- Tuple of Interaction ID and commit indices + interactionCommits: Array>>, + interactions: Array>, + operations: Array>, + rootID: number, + snapshots: Array>, +} + +-- Serializable version of ProfilingDataFrontend data. +export type ProfilingDataExport = { + -- ROBLOX TODO: Luau can't express literals/enums + -- version: 4, + version: number, + dataForRoots: Array, +} + +return exports diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/utils.lua b/packages/react-devtools-shared/src/devtools/views/Profiler/utils.lua new file mode 100644 index 00000000..8088c1f7 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/utils.lua @@ -0,0 +1,243 @@ +--!strict +--[[* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + ]] +local Packages = script.Parent.Parent.Parent.Parent.Parent +local LuauPolyfill = require(Packages.LuauPolyfill) +local Array = LuauPolyfill.Array +local Boolean = LuauPolyfill.Boolean +local Error = LuauPolyfill.Error +local Map = LuauPolyfill.Map +local Number = LuauPolyfill.Number + +type Map = LuauPolyfill.Map +type Array = LuauPolyfill.Array + +local exports = {} + +local PROFILER_EXPORT_VERSION = require(script.Parent.Parent.Parent.Parent.constants).PROFILER_EXPORT_VERSION +local backendTypes = require(script.Parent.Parent.Parent.Parent.backend.types) +type ProfilingDataBackend = backendTypes.ProfilingDataBackend +local profilerTypes = require(script.Parent.types) +type ProfilingDataExport = profilerTypes.ProfilingDataExport +type ProfilingDataForRootExport = profilerTypes.ProfilingDataForRootExport +type ProfilingDataForRootFrontend = profilerTypes.ProfilingDataForRootFrontend +type ProfilingDataFrontend = profilerTypes.ProfilingDataFrontend +type SnapshotNode = profilerTypes.SnapshotNode + +local commitGradient = { + "var(--color-commit-gradient-0)", + "var(--color-commit-gradient-1)", + "var(--color-commit-gradient-2)", + "var(--color-commit-gradient-3)", + "var(--color-commit-gradient-4)", + "var(--color-commit-gradient-5)", + "var(--color-commit-gradient-6)", + "var(--color-commit-gradient-7)", + "var(--color-commit-gradient-8)", + "var(--color-commit-gradient-9)", +} -- Combines info from the Store (frontend) and renderer interfaces (backend) into the format required by the Profiler UI. +-- This format can then be quickly exported (and re-imported). +local function prepareProfilingDataFrontendFromBackendAndStore( + dataBackends: Array, + operationsByRootID: Map>>, + snapshotsByRootID: Map> +): ProfilingDataFrontend + local dataForRoots: Map = Map.new() + for _, dataBackend in dataBackends do + for _, ref in dataBackend.dataForRoots do + local commitData, displayName, initialTreeBaseDurations, interactionCommits, interactions, rootID = + ref.commitData, + ref.displayName, + ref.initialTreeBaseDurations, + ref.interactionCommits, + ref.interactions, + ref.rootID + local operations = operationsByRootID:get(rootID) + if operations == nil then + error(Error.new(string.format("Could not find profiling operations for root %s", tostring(rootID)))) + end + local snapshots = snapshotsByRootID:get(rootID) + if snapshots == nil then + error(Error.new(string.format("Could not find profiling snapshots for root %s", tostring(rootID)))) + end + + -- Do not filter empty commits from the profiler data! + -- We used to do this, but it was error prone (see #18798). + -- A commit may appear to be empty (no actual durations) because of component filters, + -- but filtering these empty commits causes interaction commit indices to be off by N. + -- This not only corrupts the resulting data, but also potentially causes runtime errors. + -- + -- For that matter, hiding "empty" commits might cause confusion too. + -- A commit *did happen* even if none of the components the Profiler is showing were involved. + local convertedCommitData = Array.map(commitData, function(commitDataBackend, commitIndex) + return { + changeDescriptions = if commitDataBackend.changeDescriptions ~= nil + then Map.new(commitDataBackend.changeDescriptions) + else nil, + duration = commitDataBackend.duration, + fiberActualDurations = Map.new(commitDataBackend.fiberActualDurations), + fiberSelfDurations = Map.new(commitDataBackend.fiberSelfDurations), + interactionIDs = commitDataBackend.interactionIDs, + priorityLevel = commitDataBackend.priorityLevel, + timestamp = commitDataBackend.timestamp, + } + end) + dataForRoots:set(rootID, { + commitData = convertedCommitData, + displayName = displayName, + initialTreeBaseDurations = Map.new(initialTreeBaseDurations), + interactionCommits = Map.new(interactionCommits), + interactions = Map.new(interactions), + -- ROBLOX FIXME Luau: need type states to not need manual annotation + operations = operations :: Array>, + rootID = rootID, + -- ROBLOX FIXME Luau: need type states to not need manual annotation + snapshots = snapshots :: Map, + }) + end + end + return { + dataForRoots = dataForRoots, + imported = false, + } +end + +-- Converts a Profiling data export into the format required by the Store. +exports.prepareProfilingDataFrontendFromBackendAndStore = prepareProfilingDataFrontendFromBackendAndStore +local function prepareProfilingDataFrontendFromExport(profilingDataExport: ProfilingDataExport): ProfilingDataFrontend + local version_ = profilingDataExport.version + if version_ ~= PROFILER_EXPORT_VERSION then + error(string.format('Unsupported profiler export version "%s"', tostring(version_))) + end + local dataForRoots: Map = Map.new() + Array.forEach(profilingDataExport.dataForRoots, function(ref) + local commitData, displayName, initialTreeBaseDurations, interactionCommits, interactions, operations, rootID, snapshots = + ref.commitData, + ref.displayName, + ref.initialTreeBaseDurations, + ref.interactionCommits, + ref.interactions, + ref.operations, + ref.rootID, + ref.snapshots + dataForRoots:set(rootID, { + commitData = Array.map(commitData, function(ref) + local changeDescriptions, duration, fiberActualDurations, fiberSelfDurations, interactionIDs, priorityLevel, timestamp = + ref.changeDescriptions, + ref.duration, + ref.fiberActualDurations, + ref.fiberSelfDurations, + ref.interactionIDs, + ref.priorityLevel, + ref.timestamp + return { + changeDescriptions = if changeDescriptions ~= nil then Map.new(changeDescriptions) else nil, + duration = duration, + fiberActualDurations = Map.new(fiberActualDurations), + fiberSelfDurations = Map.new(fiberSelfDurations), + interactionIDs = interactionIDs, + priorityLevel = priorityLevel, + timestamp = timestamp, + } + end), + displayName = displayName, + initialTreeBaseDurations = Map.new(initialTreeBaseDurations), + interactionCommits = Map.new(interactionCommits), + interactions = Map.new(interactions), + operations = operations, + rootID = rootID, + snapshots = Map.new(snapshots), + }) + end) + return { dataForRoots = dataForRoots, imported = true } +end +exports.prepareProfilingDataFrontendFromExport = prepareProfilingDataFrontendFromExport -- Converts a Store Profiling data into a format that can be safely (JSON) serialized for export. +local function prepareProfilingDataExport(profilingDataFrontend: ProfilingDataFrontend): ProfilingDataExport + local dataForRoots: Array = {} + profilingDataFrontend.dataForRoots:forEach(function(ref) + local commitData, displayName, initialTreeBaseDurations, interactionCommits, interactions, operations, rootID, snapshots = + ref.commitData, + ref.displayName, + ref.initialTreeBaseDurations, + ref.interactionCommits, + ref.interactions, + ref.operations, + ref.rootID, + ref.snapshots + table.insert(dataForRoots, { + commitData = Array.map(commitData, function(ref) + local changeDescriptions, duration, fiberActualDurations, fiberSelfDurations, interactionIDs, priorityLevel, timestamp = + ref.changeDescriptions, + ref.duration, + ref.fiberActualDurations, + ref.fiberSelfDurations, + ref.interactionIDs, + ref.priorityLevel, + ref.timestamp + return { + changeDescriptions = if changeDescriptions ~= nil + -- ROBLOX FIXME: types aren't flowing from entries through to return value of Array.from + then Array.from(changeDescriptions:entries()) :: Array> + else nil, + duration = duration, + fiberActualDurations = Array.from(fiberActualDurations:entries()) :: Array>, + fiberSelfDurations = Array.from(fiberSelfDurations:entries()) :: Array>, + interactionIDs = interactionIDs, + priorityLevel = priorityLevel, + timestamp = timestamp, + } + end), + displayName = displayName, + -- ROBLOX FIXME: types aren't flowing from entries through to return value of Array.from + initialTreeBaseDurations = Array.from(initialTreeBaseDurations:entries()) :: Array>, + interactionCommits = Array.from(interactionCommits:entries()) :: Array | number>>, + interactions = Array.from(interactions:entries()) :: Array>, + operations = operations, + rootID = rootID, + snapshots = Array.from(snapshots:entries()) :: Array>, + }) + end) + return { version = PROFILER_EXPORT_VERSION, dataForRoots = dataForRoots } +end +exports.prepareProfilingDataExport = prepareProfilingDataExport +local function getGradientColor(value: number) + local maxIndex = #commitGradient + local index + if Number.isNaN(value) then + index = 0 + elseif not Number.isFinite(value) then + index = maxIndex + else + index = math.max(0, math.min(maxIndex, value)) * maxIndex + end + return commitGradient[math.round(index)] +end +exports.getGradientColor = getGradientColor +local function formatDuration(duration: number) + local ref = math.round(duration * 10) / 10 + return if Boolean.toJSBoolean(ref) then ref else "<0.1" +end +exports.formatDuration = formatDuration +local function formatPercentage(percentage: number) + return math.round(percentage * 100) +end +exports.formatPercentage = formatPercentage +local function formatTime(timestamp: number) + return math.round(math.round(timestamp) / 100) / 10 +end +exports.formatTime = formatTime +local function scale(minValue: number, maxValue: number, minRange: number, maxRange: number) + return function(value: number, fallbackValue: number) + return if maxValue - minValue == 0 + then fallbackValue + else (value - minValue) / (maxValue - minValue) * (maxRange - minRange) + end +end +exports.scale = scale +return exports diff --git a/packages/react-devtools-shared/src/events.lua b/packages/react-devtools-shared/src/events.lua new file mode 100644 index 00000000..a7dbb7a6 --- /dev/null +++ b/packages/react-devtools-shared/src/events.lua @@ -0,0 +1,103 @@ +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/events.js +-- /* +-- * Copyright (c) Facebook, Inc. and its affiliates. +-- * +-- * This source code is licensed under the MIT license found in the +-- * LICENSE file in the root directory of this source tree. +-- * +-- */ + +local Packages = script.Parent.Parent +local LuauPolyfill = require(Packages.LuauPolyfill) +local Array = LuauPolyfill.Array +local Map = LuauPolyfill.Map +type Array = LuauPolyfill.Array +type Map = LuauPolyfill.Map +type Function = (...any) -> ...any +type ElementType = any +type EventListener = (...ElementType) -> ...any + +export type EventEmitter = { + listenersMap: Map>, + -- ROBLOX TODO: function generics >( + addListener: (self: EventEmitter, event: string, listener: EventListener) -> (), + -- ROBLOX TODO: function generics >( + emit: (EventEmitter, string, ...ElementType) -> (), + removeAllListeners: (EventEmitter) -> (), + -- ROBLOX deviation: Luau doesn't support $Keys for first non-self param + removeListener: (self: EventEmitter, event: string, listener: Function) -> (), +} +type EventEmitter_statics = { + new: () -> EventEmitter, +} +local EventEmitter: EventEmitter & EventEmitter_statics = ({} :: any) :: EventEmitter & EventEmitter_statics +local EventEmitterMetatable = { __index = EventEmitter } + +function EventEmitter.new(): EventEmitter + local self = {} + self.listenersMap = Map.new() + + return (setmetatable(self, EventEmitterMetatable) :: any) :: EventEmitter +end + +function EventEmitter:addListener(event: string, listener: EventListener): () + local listeners = self.listenersMap:get(event) + if listeners == nil then + self.listenersMap:set(event, { listener }) + else + local index = Array.indexOf(listeners :: Array, listener) + if index < 1 then + table.insert(listeners, listener) + end + end +end + +-- ROBLOX deviation: Luau doesn't support $Keys for first non-self param +function EventEmitter:emit(event: string, ...: ElementType): () + local listeners = self.listenersMap:get(event) + if listeners ~= nil then + if #listeners == 1 then + -- No need to clone or try/catch + local listener = listeners[1] + listener(...) + else + local didThrow = false + local caughtError = nil + local clonedListeners = table.clone(listeners) + for _, listener in clonedListeners do + local ok, error_ = pcall(function(...) + listener(...) + return nil + end, ...) + if not ok then + didThrow = true + caughtError = error_ + end + end + if didThrow then + -- ROBLOX note: stringify error to avoid "nil output from lua" error + error(tostring(caughtError)) + end + end + end +end + +function EventEmitter:removeAllListeners(): () + self.listenersMap:clear() +end + +-- ROBLOX deviation: Luau doesn't support $Keys for first non-self param +function EventEmitter:removeListener(event: string, listener: Function): () + local listeners = self.listenersMap:get(event) + + if listeners ~= nil then + local index = Array.indexOf(listeners, listener) + + if index >= 1 then + Array.splice(listeners, index, 1) + end + end +end + +return EventEmitter diff --git a/packages/react-devtools-shared/src/hook.lua b/packages/react-devtools-shared/src/hook.lua new file mode 100644 index 00000000..76315155 --- /dev/null +++ b/packages/react-devtools-shared/src/hook.lua @@ -0,0 +1,200 @@ +--!strict +-- ROBLOX upstream: https://raw.githubusercontent.com/facebook/react/v17.0.1/packages/react-devtools-shared/src/hook.js +--[[* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. +]] +local Packages = script.Parent.Parent + +local LuauPolyfill = require(Packages.LuauPolyfill) +local Array = LuauPolyfill.Array +local Map = LuauPolyfill.Map +local Set = LuauPolyfill.Set +type Set = LuauPolyfill.Set +type Map = LuauPolyfill.Map +type Function = (...any) -> any +local exports = {} + +local console = require(script.Parent.backend.console) +local patchConsole = console.patch +local registerRendererWithConsole = console.registerRenderer + +local BackendTypes = require(script.Parent.backend.types) +type DevToolsHook = BackendTypes.DevToolsHook + +local window = _G + +exports.installHook = function(target: any): DevToolsHook | nil + if target["__REACT_DEVTOOLS_GLOBAL_HOOK__"] then + return nil + end + + -- ROBLOX deviation: hoist decls to top + local hook: DevToolsHook + -- ROBLOX deviation: always false, only relevant in context of optimizing bundler + local hasDetectedBadDCE = false + -- TODO: More meaningful names for "rendererInterfaces" and "renderers". + local fiberRoots = {} + local rendererInterfaces = Map.new() + local listeners = {} + local renderers = Map.new() + + local function detectReactBuildType(renderer) + -- ROBLOX TODO? do we need to distinguish between prod and dev bundles? + return "production" + end + local function checkDCE(fn: Function) + -- ROBLOX deviation: not needed in the absence of optimizing bundler + end + + -- ROBLOX deviation: start at 1 + local uidCounter = 1 + local function PREFIX_INCREMENT() + uidCounter += 1 + return uidCounter + end + + local function inject(renderer) + local id = PREFIX_INCREMENT() + + renderers:set(id, renderer) + + local reactBuildType = if hasDetectedBadDCE then "deadcode" else detectReactBuildType(renderer) + + -- ROBLOX deviation: instead of checking if `process.env.NODE_ENV ~= "production"` + -- we use the __DEV__ global + if _G.__DEV__ then + pcall(function() + local appendComponentStack = window.__REACT_DEVTOOLS_APPEND_COMPONENT_STACK__ ~= false + local breakOnConsoleErrors = window.__REACT_DEVTOOLS_BREAK_ON_CONSOLE_ERRORS__ == true + + -- The installHook() function is injected by being stringified in the browser, + -- so imports outside of this function do not get included. + -- + -- Normally we could check "type patchConsole === 'function'", + -- but Webpack wraps imports with an object (e.g. _backend_console__WEBPACK_IMPORTED_MODULE_0__) + -- and the object itself will be undefined as well for the reasons mentioned above, + -- so we use try/catch instead. + if appendComponentStack or breakOnConsoleErrors then + registerRendererWithConsole(renderer) + patchConsole({ + appendComponentStack = appendComponentStack, + breakOnConsoleErrors = breakOnConsoleErrors, + }) + end + end) + end + + local attach = target.__REACT_DEVTOOLS_ATTACH__ + + if type(attach) == "function" then + local rendererInterface = attach(hook, id, renderer, target) + hook.rendererInterfaces:set(id, rendererInterface) + end + + hook.emit("renderer", { + id = id, + renderer = renderer, + reactBuildType = reactBuildType, + }) + return id + end + + local function sub(event: string, fn: (any) -> ()) + hook.on(event, fn) + return function() + return hook.off(event, fn) + end + end + local function on(event, fn) + if not listeners[event] then + listeners[event] = {} + end + table.insert(listeners[event], fn) + end + local function off(event, fn) + if not listeners[event] then + return + end + + local index = Array.indexOf(listeners[event], fn) + + if index ~= -1 then + Array.splice(listeners[event], index, 1) + end + if #listeners[event] == 0 then + listeners[event] = nil + end + end + local function emit(event, data) + if listeners[event] then + for _, fn in listeners[event] do + fn(data) + end + end + end + local function getFiberRoots(rendererID) + local roots = fiberRoots + + if not roots[rendererID] then + roots[rendererID] = Set.new() + end + + return roots[rendererID] + end + local function onCommitFiberUnmount(rendererID, fiber) + local rendererInterface = rendererInterfaces:get(rendererID) + + if rendererInterface ~= nil then + rendererInterface.handleCommitFiberUnmount(fiber) + end + end + local function onCommitFiberRoot(rendererID, root, priorityLevel) + local mountedRoots = hook.getFiberRoots(rendererID) + local current = root.current + local isKnownRoot = mountedRoots[root] ~= nil + local isUnmounting = current.memoizedState == nil or current.memoizedState.element == nil + + if not isKnownRoot and not isUnmounting then + mountedRoots[root] = true + elseif isKnownRoot and isUnmounting then + mountedRoots[root] = nil + end + + local rendererInterface = rendererInterfaces:get(rendererID) + + if rendererInterface ~= nil then + rendererInterface.handleCommitFiberRoot(root, priorityLevel) + end + end + + hook = { + rendererInterfaces = rendererInterfaces, + listeners = listeners, + -- Fast Refresh for web relies on this. + renderers = renderers, + + emit = emit, + getFiberRoots = getFiberRoots, + inject = inject, + on = on, + off = off, + sub = sub, + + -- This is a legacy flag. + -- React v16 checks the hook for this to ensure DevTools is new enough. + supportsFiber = true, + + -- React calls these methods. + checkDCE = checkDCE, + onCommitFiberUnmount = onCommitFiberUnmount, + onCommitFiberRoot = onCommitFiberRoot, + } + + target["__REACT_DEVTOOLS_GLOBAL_HOOK__"] = hook + return hook +end + +return exports diff --git a/packages/react-devtools-shared/src/hydration.lua b/packages/react-devtools-shared/src/hydration.lua new file mode 100644 index 00000000..3bb5f454 --- /dev/null +++ b/packages/react-devtools-shared/src/hydration.lua @@ -0,0 +1,392 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/hydration.js +-- /* +-- * Copyright (c) Facebook, Inc. and its affiliates. +-- * +-- * This source code is licensed under the MIT license found in the +-- * LICENSE file in the root directory of this source tree. +-- */ + +local Packages = script.Parent.Parent + +local LuauPolyfill = require(Packages.LuauPolyfill) +local Symbol = LuauPolyfill.Symbol +type Array = { [number]: T } +type Object = { [string]: any } + +-- ROBLOX FIXME: !!! THIS FILE IS A STUB WITH BAREBONES FOR UTILS TEST +local function unimplemented(functionName: string) + print("!!!!!!!!!!!!!!!!!!!!!!!!!!!!") + print("!!!!!!!!!!!!!!!!!!!!!!!!!!!!") + print("!!! " .. functionName .. " was called, but is stubbed! ") +end + +local exports = {} + +--ROBLOX TODO: circular dependency, inline for now and submit PR to fix upstream +--local ComponentsTypes = require(script.Parent.devtools.views.Components.types) +export type DehydratedData = { + cleaned: Array>, + data: string + | Dehydrated + | Unserializable + | Array + | Array + | { [string]: string | Dehydrated | Unserializable }, + unserializable: Array>, +} + +exports.meta = { + inspectable = Symbol("inspectable"), + inspected = Symbol("inspected"), + name = Symbol("name"), + preview_long = Symbol("preview_long"), + preview_short = Symbol("preview_short"), + readonly = Symbol("readonly"), + size = Symbol("size"), + type = Symbol("type"), + unserializable = Symbol("unserializable"), +} + +export type Dehydrated = { + inspectable: boolean, + name: string | nil, + preview_long: string | nil, + preview_short: string | nil, + readonly: boolean?, + size: number?, + type: string, +} + +-- Typed arrays and other complex iteratable objects (e.g. Map, Set, ImmutableJS) need special handling. +-- These objects can't be serialized without losing type information, +-- so a "Unserializable" type wrapper is used (with meta-data keys) to send nested values- +-- while preserving the original type and name. +export type Unserializable = { + name: string | nil, + preview_long: string | nil, + preview_short: string | nil, + readonly: boolean?, + size: number?, + type: string, + unserializable: boolean, + -- ... +} + +-- This threshold determines the depth at which the bridge "dehydrates" nested data. +-- Dehydration means that we don't serialize the data for e.g. postMessage or stringify, +-- unless the frontend explicitly requests it (e.g. a user clicks to expand a props object). +-- +-- Reducing this threshold will improve the speed of initial component inspection, +-- but may decrease the responsiveness of expanding objects/arrays to inspect further. +local _LEVEL_THRESHOLD = 2 + +-- /** +-- * Generate the dehydrated metadata for complex object instances +-- */ +exports.createDehydrated = function( + type: string, + inspectable: boolean, + data: Object, + cleaned: Array>, + path: Array +): Dehydrated + unimplemented("createDehydrated") + error("unimplemented createDehydrated") +end + +-- /** +-- * Strip out complex data (instances, functions, and data nested > LEVEL_THRESHOLD levels deep). +-- * The paths of the stripped out objects are appended to the `cleaned` list. +-- * On the other side of the barrier, the cleaned list is used to "re-hydrate" the cleaned representation into +-- * an object with symbols as attributes, so that a sanitized object can be distinguished from a normal object. +-- * +-- * Input: {"some": {"attr": fn()}, "other": AnInstance} +-- * Output: { +-- * "some": { +-- * "attr": {"name": the fn.name, type: "function"} +-- * }, +-- * "other": { +-- * "name": "AnInstance", +-- * "type": "object", +-- * }, +-- * } +-- * and cleaned = [["some", "attr"], ["other"]] +-- */ +exports.dehydrate = function( + data: Object, + cleaned: Array>, + unserializable: Array>, + path: Array, + isPathAllowed: (Array) -> boolean, + level: number? +): string | Dehydrated | Unserializable | Array | Array | { + [string]: string | Dehydrated | Unserializable, --[[...]] +} + if level == nil then + level = 0 + end + + -- ROBLOX TODO: port this properly, for now just do the default case + -- let isPathAllowedCheck; + + -- switch (type) { + -- case 'html_element': + -- cleaned.push(path); + -- return { + -- inspectable: false, + -- preview_short: formatDataForPreview(data, false), + -- preview_long: formatDataForPreview(data, true), + -- name: data.tagName, + -- type, + -- }; + + -- case 'function': + -- cleaned.push(path); + -- return { + -- inspectable: false, + -- preview_short: formatDataForPreview(data, false), + -- preview_long: formatDataForPreview(data, true), + -- name: + -- typeof data.name === 'function' || !data.name + -- ? 'function' + -- : data.name, + -- type, + -- }; + + -- case 'string': + -- isPathAllowedCheck = isPathAllowed(path); + -- if (isPathAllowedCheck) { + -- return data; + -- } else { + -- return data.length <= 500 ? data : data.slice(0, 500) + '...'; + -- } + + -- case 'bigint': + -- cleaned.push(path); + -- return { + -- inspectable: false, + -- preview_short: formatDataForPreview(data, false), + -- preview_long: formatDataForPreview(data, true), + -- name: data.toString(), + -- type, + -- }; + + -- case 'symbol': + -- cleaned.push(path); + -- return { + -- inspectable: false, + -- preview_short: formatDataForPreview(data, false), + -- preview_long: formatDataForPreview(data, true), + -- name: data.toString(), + -- type, + -- }; + + -- // React Elements aren't very inspector-friendly, + -- // and often contain private fields or circular references. + -- case 'react_element': + -- cleaned.push(path); + -- return { + -- inspectable: false, + -- preview_short: formatDataForPreview(data, false), + -- preview_long: formatDataForPreview(data, true), + -- name: getDisplayNameForReactElement(data) || 'Unknown', + -- type, + -- }; + + -- // ArrayBuffers error if you try to inspect them. + -- case 'array_buffer': + -- case 'data_view': + -- cleaned.push(path); + -- return { + -- inspectable: false, + -- preview_short: formatDataForPreview(data, false), + -- preview_long: formatDataForPreview(data, true), + -- name: type === 'data_view' ? 'DataView' : 'ArrayBuffer', + -- size: data.byteLength, + -- type, + -- }; + + -- case 'array': + -- isPathAllowedCheck = isPathAllowed(path); + -- if (level >= LEVEL_THRESHOLD && !isPathAllowedCheck) { + -- return createDehydrated(type, true, data, cleaned, path); + -- } + -- return data.map((item, i) => + -- dehydrate( + -- item, + -- cleaned, + -- unserializable, + -- path.concat([i]), + -- isPathAllowed, + -- isPathAllowedCheck ? 1 : level + 1, + -- ), + -- ); + + -- case 'html_all_collection': + -- case 'typed_array': + -- case 'iterator': + -- isPathAllowedCheck = isPathAllowed(path); + -- if (level >= LEVEL_THRESHOLD && !isPathAllowedCheck) { + -- return createDehydrated(type, true, data, cleaned, path); + -- } else { + -- const unserializableValue: Unserializable = { + -- unserializable: true, + -- type: type, + -- readonly: true, + -- size: type === 'typed_array' ? data.length : undefined, + -- preview_short: formatDataForPreview(data, false), + -- preview_long: formatDataForPreview(data, true), + -- name: + -- !data.constructor || data.constructor.name === 'Object' + -- ? '' + -- : data.constructor.name, + -- }; + + -- // TRICKY + -- // Don't use [...spread] syntax for this purpose. + -- // This project uses @babel/plugin-transform-spread in "loose" mode which only works with Array values. + -- // Other types (e.g. typed arrays, Sets) will not spread correctly. + -- Array.from(data).forEach( + -- (item, i) => + -- (unserializableValue[i] = dehydrate( + -- item, + -- cleaned, + -- unserializable, + -- path.concat([i]), + -- isPathAllowed, + -- isPathAllowedCheck ? 1 : level + 1, + -- )), + -- ); + + -- unserializable.push(path); + + -- return unserializableValue; + -- } + + -- case 'opaque_iterator': + -- cleaned.push(path); + -- return { + -- inspectable: false, + -- preview_short: formatDataForPreview(data, false), + -- preview_long: formatDataForPreview(data, true), + -- name: data[Symbol.toStringTag], + -- type, + -- }; + + -- case 'date': + -- cleaned.push(path); + -- return { + -- inspectable: false, + -- preview_short: formatDataForPreview(data, false), + -- preview_long: formatDataForPreview(data, true), + -- name: data.toString(), + -- type, + -- }; + + -- case 'regexp': + -- cleaned.push(path); + -- return { + -- inspectable: false, + -- preview_short: formatDataForPreview(data, false), + -- preview_long: formatDataForPreview(data, true), + -- name: data.toString(), + -- type, + -- }; + + -- case 'object': + -- isPathAllowedCheck = isPathAllowed(path); + -- if (level >= LEVEL_THRESHOLD && !isPathAllowedCheck) { + -- return createDehydrated(type, true, data, cleaned, path); + -- } else { + -- const object = {}; + -- getAllEnumerableKeys(data).forEach(key => { + -- const name = key.toString(); + -- object[name] = dehydrate( + -- data[key], + -- cleaned, + -- unserializable, + -- path.concat([name]), + -- isPathAllowed, + -- isPathAllowedCheck ? 1 : level + 1, + -- ); + -- }); + -- return object; + -- } + + -- case 'infinity': + -- case 'nan': + -- case 'undefined': + -- // Some values are lossy when sent through a WebSocket. + -- // We dehydrate+rehydrate them to preserve their type. + -- cleaned.push(path); + -- return { + -- type, + -- }; + + -- default: + return data +end + +exports.fillInPath = function(object: Object, data: DehydratedData, path: Array, value: any): () + unimplemented("fillInPath") +end + +exports.hydrate = function( + object: any, + cleaned: Array>, + unserializable: Array> +): Object + -- ROBLOX TODO: port this properly later, for now return the default + -- const length = path.length; + -- const last = path[length - 1]; + -- const parent = getInObject(object, path.slice(0, length - 1)); + -- if (!parent || !parent.hasOwnProperty(last)) { + -- return; + -- } + + -- const value = parent[last]; + + -- if (value.type === 'infinity') { + -- parent[last] = Infinity; + -- } else if (value.type === 'nan') { + -- parent[last] = NaN; + -- } else if (value.type === 'undefined') { + -- parent[last] = undefined; + -- } else { + -- // Replace the string keys with Symbols so they're non-enumerable. + -- const replaced: {[key: Symbol]: boolean | string, ...} = {}; + -- replaced[meta.inspectable] = !!value.inspectable; + -- replaced[meta.inspected] = false; + -- replaced[meta.name] = value.name; + -- replaced[meta.preview_long] = value.preview_long; + -- replaced[meta.preview_short] = value.preview_short; + -- replaced[meta.size] = value.size; + -- replaced[meta.readonly] = !!value.readonly; + -- replaced[meta.type] = value.type; + + -- parent[last] = replaced; + -- } + -- }); + -- unserializable.forEach((path: Array) => { + -- const length = path.length; + -- const last = path[length - 1]; + -- const parent = getInObject(object, path.slice(0, length - 1)); + -- if (!parent || !parent.hasOwnProperty(last)) { + -- return; + -- } + + -- const node = parent[last]; + + -- const replacement = { + -- ...node, + -- }; + + -- upgradeUnserializable(replacement, node); + + -- parent[last] = replacement; + -- }); + + return object +end + +return exports diff --git a/packages/react-devtools-shared/src/init.lua b/packages/react-devtools-shared/src/init.lua new file mode 100644 index 00000000..dab8d8a6 --- /dev/null +++ b/packages/react-devtools-shared/src/init.lua @@ -0,0 +1,8 @@ +return { + backend = require(script.backend), + bridge = require(script.bridge), + devtools = require(script.devtools), + hydration = require(script.hydration), + hook = require(script.hook), + utils = require(script.utils), +} diff --git a/packages/react-devtools-shared/src/storage.lua b/packages/react-devtools-shared/src/storage.lua new file mode 100644 index 00000000..c6a7c483 --- /dev/null +++ b/packages/react-devtools-shared/src/storage.lua @@ -0,0 +1,42 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/storage.js +-- /* +-- * Copyright (c) Facebook, Inc. and its affiliates. +-- * +-- * This source code is licensed under the MIT license found in the +-- * LICENSE file in the root directory of this source tree. +-- * +-- */ + +local exports = {} +if _G.__LOCALSTORAGE__ == nil then + _G.__LOCALSTORAGE__ = {} +end + +if _G.__SESSIONSTORAGE__ == nil then + _G.__SESSIONSTORAGE__ = {} +end + +-- ROBLOX FIXME: what's a high-performance storage that for temporal (current DM lifetime) and permanent (beyond current DM lifetime) +local localStorage = _G.__LOCALSTORAGE__ +local sessionStorage = _G.__SESSIONSTORAGE__ + +exports.localStorageGetItem = function(key: string): any + return localStorage[key] +end +exports.localStorageRemoveItem = function(key: string): () + localStorage[key] = nil +end +exports.localStorageSetItem = function(key: string, value: any): () + localStorage[key] = value +end +exports.sessionStorageGetItem = function(key: string): any + return sessionStorage[key] +end +exports.sessionStorageRemoveItem = function(key: string): () + sessionStorage[key] = nil +end +exports.sessionStorageSetItem = function(key: string, value: any): () + sessionStorage[key] = value +end + +return exports diff --git a/packages/react-devtools-shared/src/types.lua b/packages/react-devtools-shared/src/types.lua new file mode 100644 index 00000000..5a71a42f --- /dev/null +++ b/packages/react-devtools-shared/src/types.lua @@ -0,0 +1,85 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/types.js +-- /* +-- * Copyright (c) Facebook, Inc. and its affiliates. +-- * +-- * This source code is licensed under the MIT license found in the +-- * LICENSE file in the root directory of this source tree. +-- */ + +type Array = { [number]: T } +type Function = (...any) -> ...any +local exports = {} + +-- WARNING +-- The values below are referenced by ComponentFilters (which are saved via localStorage). +-- Do not change them or it will break previously saved user customizations. +-- +-- If new element types are added, use new numbers rather than re-ordering existing ones. +-- Changing these types is also a backwards breaking change for the standalone shell, +-- since the frontend and backend must share the same values- +-- and the backend is embedded in certain environments (like React Native). + +export type Wall = { + listen: (Function) -> Function, + send: (string, any, Array) -> (), +} + +exports.ElementTypeClass = 1 +exports.ElementTypeContext = 2 +exports.ElementTypeFunction = 5 +exports.ElementTypeForwardRef = 6 +exports.ElementTypeHostComponent = 7 +exports.ElementTypeMemo = 8 +exports.ElementTypeOtherOrUnknown = 9 +exports.ElementTypeProfiler = 10 +exports.ElementTypeRoot = 11 +exports.ElementTypeSuspense = 12 +exports.ElementTypeSuspenseList = 13 + +-- Different types of elements displayed in the Elements tree. +-- These types may be used to visually distinguish types, +-- or to enable/disable certain functionality. +-- ROBLOX deviation: Luau doesn't support literals as types: 1 | 2 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 +export type ElementType = number + +-- WARNING +-- The values below are referenced by ComponentFilters (which are saved via localStorage). +-- Do not change them or it will break previously saved user customizations. +-- If new filter types are added, use new numbers rather than re-ordering existing ones. +exports.ComponentFilterElementType = 1 +exports.ComponentFilterDisplayName = 2 +exports.ComponentFilterLocation = 3 +exports.ComponentFilterHOC = 4 + +-- ROBLOX deviation: Luau doesn't support literals as types: 1 | 2 | 3 | 4 +export type ComponentFilterType = number + +-- Hide all elements of types in this Set. +-- We hide host components only by default. +export type ElementTypeComponentFilter = { + isEnabled: boolean, + -- ROBLOX deviation: Luau doesn't support literals as types: 1 + type: number, + value: ElementType, +} + +-- Hide all elements with displayNames or paths matching one or more of the RegExps in this Set. +-- Path filters are only used when elements include debug source location. +export type RegExpComponentFilter = { + isEnabled: boolean, + isValid: boolean, + -- ROBLOX deviation: Luau doesn't support literals as types: 2 | 3 + type: number, + value: string, +} + +export type BooleanComponentFilter = { + isEnabled: boolean, + isValid: boolean, + -- ROBLOX deviation: Luau doesn't support literals as types: 4 + type: number, +} + +export type ComponentFilter = BooleanComponentFilter | ElementTypeComponentFilter | RegExpComponentFilter + +return exports diff --git a/packages/react-devtools-shared/src/utils.lua b/packages/react-devtools-shared/src/utils.lua new file mode 100644 index 00000000..87b9567a --- /dev/null +++ b/packages/react-devtools-shared/src/utils.lua @@ -0,0 +1,702 @@ +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/utils.js +-- /* +-- * Copyright (c) Facebook, Inc. and its affiliates. +-- * +-- * This source code is licensed under the MIT license found in the +-- */ +-- * LICENSE file in the root directory of this source tree. + +local Packages = script.Parent.Parent + +local LuauPolyfill = require(Packages.LuauPolyfill) +local Array = LuauPolyfill.Array +local WeakMap = LuauPolyfill.WeakMap +local Number = LuauPolyfill.Number +local Object = LuauPolyfill.Object +type WeakMap = LuauPolyfill.WeakMap +type Function = (...any) -> ...any +type Object = LuauPolyfill.Object +type Array = LuauPolyfill.Array +local JSON = game:GetService("HttpService") + +local exports = {} + +-- ROBLOX TODO: pull in smarter cache when there's a performance reason to do so +-- local LRU = require() +-- ROBLOX deviation: pull in getComponentName for Lua-specific logic to extract component names +local Shared = require(Packages.Shared) +local getComponentName = Shared.getComponentName + +local ReactIs = require(Packages.ReactIs) +local isElement = ReactIs.isElement +local typeOf = ReactIs.typeOf +local ContextConsumer = ReactIs.ContextConsumer +local ContextProvider = ReactIs.ContextProvider +local ForwardRef = ReactIs.ForwardRef +local Fragment = ReactIs.Fragment +local Lazy = ReactIs.Lazy +local Memo = ReactIs.Memo +local Portal = ReactIs.Portal +local Profiler = ReactIs.Profiler +local StrictMode = ReactIs.StrictMode +local Suspense = ReactIs.Suspense +local ReactSymbols = require(Packages.Shared).ReactSymbols +local SuspenseList = ReactSymbols.REACT_SUSPENSE_LIST_TYPE +local constants = require(script.Parent.constants) +local TREE_OPERATION_ADD = constants.TREE_OPERATION_ADD +local TREE_OPERATION_REMOVE = constants.TREE_OPERATION_REMOVE +local TREE_OPERATION_REORDER_CHILDREN = constants.TREE_OPERATION_REORDER_CHILDREN +local TREE_OPERATION_UPDATE_TREE_BASE_DURATION = constants.TREE_OPERATION_UPDATE_TREE_BASE_DURATION +local types = require(script.Parent.types) +local ElementTypeRoot = types.ElementTypeRoot +local LOCAL_STORAGE_FILTER_PREFERENCES_KEY = constants.LOCAL_STORAGE_FILTER_PREFERENCES_KEY +local LOCAL_STORAGE_SHOULD_BREAK_ON_CONSOLE_ERRORS = constants.LOCAL_STORAGE_SHOULD_BREAK_ON_CONSOLE_ERRORS +local LOCAL_STORAGE_SHOULD_PATCH_CONSOLE_KEY = constants.LOCAL_STORAGE_SHOULD_PATCH_CONSOLE_KEY +local ComponentFilterElementType = types.ComponentFilterElementType +local ElementTypeHostComponent = types.ElementTypeHostComponent +local ElementTypeClass = types.ElementTypeClass +local ElementTypeForwardRef = types.ElementTypeForwardRef +local ElementTypeFunction = types.ElementTypeFunction +local ElementTypeMemo = types.ElementTypeMemo +local storage = require(script.Parent.storage) +local localStorageGetItem = storage.localStorageGetItem +local localStorageSetItem = storage.localStorageSetItem +local hydration = require(script.Parent.hydration) +local meta = hydration.meta +type ComponentFilter = types.ComponentFilter +type ElementType = types.ElementType + +local cachedDisplayNames: WeakMap = WeakMap.new() + +-- On large trees, encoding takes significant time. +-- Try to reuse the already encoded strings. +-- ROBLOX TODO: implement this when there's a performance issue in Studio tools driving it +-- local encodedStringCache = LRU({max = 1000}) + +exports.alphaSortKeys = function( + a: string | number, -- ROBLOX deviation: | Symbol, + b: string | number -- ROBLOX deviation: | Symbol, +): boolean + -- ROBLOX deviation: passed to table.sort(), which returns a bool + return tostring(a) > tostring(b) +end + +exports.getAllEnumerableKeys = function(obj: Object): Array -- | Symbol> + -- ROBLOX TODO: we probably need to enumerate inheritance chain metatables + return Object.keys(obj) +end + +exports.getDisplayName = function(type_: any, fallbackName: string?): string + fallbackName = fallbackName or "Anonymous" + local nameFromCache = cachedDisplayNames:get(type_) + + if nameFromCache ~= nil then + return nameFromCache :: string + end + + -- ROBLOX FIXME: Luau type narrowing doesn't understand the or "anonymous" above + local displayName: string = fallbackName :: string + + -- The displayName property is not guaranteed to be a string. + -- It's only safe to use for our purposes if it's a string. + -- github.com/facebook/react-devtools/issues/803 + -- ROBLOX deviation START: Luau datatypes don't have a displayName property, so we use .__componentName + if typeof(type_) == "table" and typeof(type_.__componentName) == "string" then + displayName = type_.__componentName + -- ROBLOX deviation END + elseif typeof(type_) == "table" and typeof(type_.name) == "string" and type_.name ~= "" then + displayName = type_.name + -- ROBLOX deviation: use the Lua logic in getComponentName to extract names of function components + elseif typeof(type_) == "function" then + displayName = getComponentName(type_) or displayName + end + + cachedDisplayNames:set(type_, displayName) + + return displayName +end + +local uidCounter: number = 0 + +exports.getUID = function(): number + uidCounter += 1 + return uidCounter +end + +-- ROBLOX deviation: string encoding not required +-- exports.utfDecodeString = function(str): string +-- end +-- exports.utfEncodeString = function(str): string +-- end + +-- ROBLOX deviation: don't binary encode strings, so operations Array can include strings +exports.printOperationsArray = function(operations: Array) + -- The first two values are always rendererID and rootID + local rendererID = operations[1] :: number + local rootID = operations[2] :: number + local logs = { + string.format("operations for renderer:%s and root:%s", tostring(rendererID), tostring(rootID)), + } + + -- ROBLOX deviation: 1-indexing so start at 3 + local i = 3 + + -- ROBLOX deviation: use POSTFIX_INCREMENT instead of return i++ + local function POSTFIX_INCREMENT() + local tmp = i + i += 1 + return tmp + end + + -- Reassemble the string table. + local stringTable: Array = { + -- ROBLOX deviation: Use the empty string + "", -- ID = 0 corresponds to the empty string. + } + local stringTableSize = operations[POSTFIX_INCREMENT()] :: number + local stringTableEnd = i + stringTableSize + + -- ROBLOX deviation: adjust bounds due to 1-based indexing + while i < stringTableEnd do + -- ROBLOX deviation: don't binary encode strings, so store string directly rather than length + -- local nextLength = operations[POSTFIX_INCREMENT()] + -- local nextString = exports.utfDecodeString(Array.slice(operations, i, i + nextLength) + local nextString = operations[POSTFIX_INCREMENT()] :: string + table.insert(stringTable, nextString) + end + + while i < #operations do + local operation = operations[i] :: number + + if operation == TREE_OPERATION_ADD then + local id = operations[i + 1] :: number + local type_ = operations[i + 2] :: ElementType + + i += 3 + + if type_ == ElementTypeRoot then + table.insert(logs, string.format("Add new root node %d", id)) + + i += 1 -- supportsProfiling + i += 1 -- hasOwnerMetadata + else + local parentID = operations[i] :: number + i += 1 + + i += 1 -- ownerID + + local displayNameStringID = operations[i] :: number + local displayName = stringTable[displayNameStringID + 1] + i += 1 + + i += 1 -- key + + table.insert( + logs, + string.format("Add node %d (%s) as child of %d", id, displayName or "null", parentID) + ) + end + elseif operation == TREE_OPERATION_REMOVE then + local removeLength = operations[i + 1] :: number + i += 2 + + for removeIndex = 1, removeLength do + local id = operations[i] :: number + i += 1 + + table.insert(logs, string.format("Remove node %d", id)) + end + elseif operation == TREE_OPERATION_REORDER_CHILDREN then + local id = operations[i + 1] :: number + local numChildren = operations[i + 2] :: number + i += 3 + local children = Array.slice(operations, i, i + numChildren) + i += numChildren + + table.insert(logs, string.format("Re-order node %d children %s", id, Array.join(children, ","))) + elseif operation == TREE_OPERATION_UPDATE_TREE_BASE_DURATION then + -- Base duration updates are only sent while profiling is in progress. + -- We can ignore them at this point. + -- The profiler UI uses them lazily in order to generate the tree. + i += 3 + else + error(string.format("Unsupported Bridge operation %d", operation)) + end + end + + print(table.concat(logs, "\n ")) +end + +exports.getDefaultComponentFilters = function(): Array + return { + { + type = ComponentFilterElementType, + value = ElementTypeHostComponent, + isEnabled = true, + }, + } +end +exports.getSavedComponentFilters = function(): Array + local ok, result = pcall(function() + local raw = localStorageGetItem(LOCAL_STORAGE_FILTER_PREFERENCES_KEY) + if raw ~= nil then + return JSON:JSONDecode(raw) + end + return nil + end) + if not ok then + return exports.getDefaultComponentFilters() + end + + return result +end +exports.saveComponentFilters = function(componentFilters: Array): () + localStorageSetItem(LOCAL_STORAGE_FILTER_PREFERENCES_KEY, JSON:JSONEncode(componentFilters)) +end +exports.getAppendComponentStack = function(): boolean + local ok, result = pcall(function() + local raw = localStorageGetItem(LOCAL_STORAGE_SHOULD_PATCH_CONSOLE_KEY) + if raw ~= nil then + return JSON:JSONDecode(raw) + end + return nil + end) + if not ok then + return true + end + + return result +end +exports.setAppendComponentStack = function(value: boolean): () + localStorageSetItem(LOCAL_STORAGE_SHOULD_PATCH_CONSOLE_KEY, JSON:JSONEncode(value)) +end +exports.getBreakOnConsoleErrors = function(): boolean + local ok, result = pcall(function() + local raw = localStorageGetItem(LOCAL_STORAGE_SHOULD_BREAK_ON_CONSOLE_ERRORS) + if raw ~= nil then + return JSON:JSONDecode(raw) + end + return nil + end) + if ok then + return result + end + return false +end + +exports.setBreakOnConsoleErrors = function(value: boolean): () + localStorageSetItem(LOCAL_STORAGE_SHOULD_BREAK_ON_CONSOLE_ERRORS, JSON:JSONEncode(value)) +end +exports.separateDisplayNameAndHOCs = function( + displayName: string | nil, + type_: ElementType +): (string | nil, Array | nil) + if displayName == nil then + return nil, nil + end + + local hocDisplayNames: Array? = nil + + if + type_ == ElementTypeClass + or type_ == ElementTypeForwardRef + or type_ == ElementTypeFunction + or type_ == ElementTypeMemo + then + -- ROBLOX deviation START: use find instead of indexOf and gmatch instead of /[^()]+/g + if string.find(displayName :: string, "(", 1, true) then + local hocTable: Array = {} + for match in string.gmatch(displayName :: string, "[^()]+") do + table.insert(hocTable, match) + end + + -- ROBLOX note: Pull the last one out as the displayName + local count = #hocTable + local lastMatch = hocTable[count] + hocTable[count] = nil + + displayName = lastMatch + hocDisplayNames = hocTable + end + -- ROBLOX Deviation END + end + + if type_ == ElementTypeMemo then + if hocDisplayNames == nil then + hocDisplayNames = { "Memo" } + else + Array.unshift(hocDisplayNames :: Array, "Memo") + end + elseif type_ == ElementTypeForwardRef then + if hocDisplayNames == nil then + hocDisplayNames = { "ForwardRef" } + else + Array.unshift(hocDisplayNames :: Array, "ForwardRef") + end + end + return displayName, hocDisplayNames +end + +-- Pulled from preact-compat +-- https://github.com/developit/preact-compat/blob/7c5de00e7c85e2ffd011bf3af02899b63f699d3a/src/index.js#L349 +exports.shallowDiffers = function(prev: Object, next_: Object): boolean + for key, value in prev do + if next_[key] ~= value then + return true + end + end + return false +end + +exports.getInObject = function(object: Object, path: Array): any + return Array.reduce(path, function(reduced: Object, attr: any): any + if reduced then + if reduced[attr] ~= nil then + return reduced[attr] + end + -- ROBLOX deviation: no iterators in Symbol polyfill + -- if typeof(reduced[Symbol.iterator]) == "function" then + -- return Array.from(reduced)[attr] + -- end + end + + return nil + end, object) +end +exports.deletePathInObject = function(object: Object?, path: Array) + local length = #path + local last = path[length] :: number + + if object ~= nil then + local parent = exports.getInObject(object :: Object, Array.slice(path, 0, length)) + + if parent then + if Array.isArray(parent) then + Array.splice(parent, last, 1) + else + parent[last] = nil + end + end + end +end +exports.renamePathInObject = function(object: Object?, oldPath: Array, newPath: Array) + local length = #oldPath + + if object ~= nil then + local parent = exports.getInObject(object :: Object, Array.slice(oldPath, 1, length)) + + if parent then + local lastOld = oldPath[length] :: number + local lastNew = newPath[length] :: number + + parent[lastNew] = parent[lastOld] + + if Array.isArray(parent) then + Array.splice(parent, lastOld, 1) + else + parent[lastOld] = nil + end + end + end +end +exports.setInObject = function(object: Object?, path: Array, value) + local length = #path + local last = path[length] + + if object ~= nil then + local parent = exports.getInObject(object :: Object, Array.slice(path, 1, length)) + + if parent then + parent[last] = value + end + end +end + +-- ROBLOX deviation: Luau can't express enumeration of literals +-- export type DataType = +-- | 'array' +-- | 'array_buffer' +-- | 'bigint' +-- | 'boolean' +-- | 'data_view' +-- | 'date' +-- | 'function' +-- | 'html_all_collection' +-- | 'html_element' +-- | 'infinity' +-- | 'iterator' +-- | 'opaque_iterator' +-- | 'nan' +-- | 'null' +-- | 'number' +-- | 'object' +-- | 'react_element' +-- | 'regexp' +-- | 'string' +-- | 'symbol' +-- | 'typed_array' +-- | 'undefined' +-- | 'unknown'; +export type DataType = string + +-- /** +-- * Get a enhanced/artificial type string based on the object instance +-- */ +exports.getDataType = function(data: Object?): DataType + if data == nil then + return "null" + -- ROBLOX deviation: no undefined in Lua + -- elseif data == nil then + -- return'undefined' + end + + if isElement(data) then + return "react_element" + end + + -- ROBLOX deviation: only applies to web + -- if (typeof HTMLElement !== 'undefined' && data instanceof HTMLElement) { + -- return 'html_element'; + -- } + + local type_ = typeof(data) + if type_ == "bigint" then + return "bigint" + elseif type_ == "boolean" then + return "boolean" + elseif type_ == "function" then + return "function" + elseif type_ == "number" then + if Number.isNaN(data) then + return "nan" + elseif not Number.isFinite(data) then + return "infinity" + else + return "number" + end + elseif type_ == "object" then + if Array.isArray(data) then + return "array" + + -- ROBLOX deviation: only applies to web + -- elseif ArrayBuffer.isView(data) then + -- return Object.hasOwnProperty(data.constructor, 'BYTES_PER_ELEMENT') + -- and 'typed_array' + -- or 'data_view' + -- elseif data.constructor and data.constructor.name == 'ArrayBuffer' then + -- HACK This ArrayBuffer check is gross is there a better way? + -- We could try to create a new DataView with the value. + -- If it doesn't error, we know it's an ArrayBuffer, + -- but this seems kind of awkward and expensive. + -- return 'array_buffer' + -- elseif typeof(data[Symbol.iterator]) == 'function' then + -- return data[Symbol.iterator]() == data + -- ? 'opaque_iterator' + -- : 'iterator' + -- elseif (data.constructor and data.constructor.name == 'RegExp'then + -- return 'regexp' + -- else + -- const toStringValue = Object.prototype.toString.call(data) + -- if (toStringValue == '[object Date]'then + -- return 'date' + -- elseif (toStringValue == '[object HTMLAllCollection]'then + -- return 'html_all_collection' + -- } + -- } + else + return "object" + end + elseif type_ == "string" then + return "string" + -- ROBLOX TODO? detect our Symbol polyfill here? + -- elseif type_ == 'symbol' then + -- return 'symbol' + elseif type_ == "nil" then + -- ROBLOX deviation: skip web-specific stuff + -- if ( + -- Object.prototype.toString.call(data) == '[object HTMLAllCollection]' + -- then + -- return 'html_all_collection' + -- } + return "nil" + else + return "unknown" + end +end + +exports.getDisplayNameForReactElement = function(element): string | nil + local elementType = typeOf(element) + if elementType == ContextConsumer then + return "ContextConsumer" + elseif elementType == ContextProvider then + return "ContextProvider" + elseif elementType == ForwardRef then + return "ForwardRef" + elseif elementType == Fragment then + return "Fragment" + elseif elementType == Lazy then + return "Lazy" + elseif elementType == Memo then + return "Memo" + elseif elementType == Portal then + return "Portal" + elseif elementType == Profiler then + return "Profiler" + elseif elementType == StrictMode then + return "StrictMode" + elseif elementType == Suspense then + return "Suspense" + elseif elementType == SuspenseList then + return "SuspenseList" + else + local type_ = if element then element.type else nil + if typeof(type_) == "string" then + return type_ + elseif typeof(type_) == "function" then + return exports.getDisplayName(type_, "Anonymous") + elseif type_ ~= nil then + return "NotImplementedInDevtools" + else + return "Element" + end + end +end + +local MAX_PREVIEW_STRING_LENGTH = 50 + +local function truncateForDisplay(string_: string, length: number?) + length = length or MAX_PREVIEW_STRING_LENGTH + + if string.len(string_) > (length :: number) then + return string.sub(string_, 1, (length :: number) + 1) .. "…" + else + return string_ + end +end + +-- Attempts to mimic Chrome's inline preview for values. +-- For example, the following value... +-- { +-- foo: 123, +-- bar: "abc", +-- baz: [true, false], +-- qux: { ab: 1, cd: 2 } +-- }; +-- +-- Would show a preview of... +-- {foo: 123, bar: "abc", baz: Array(2), qux: {…}} +-- +-- And the following value... +-- [ +-- 123, +-- "abc", +-- [true, false], +-- { foo: 123, bar: "abc" } +-- ]; +-- +-- Would show a preview of... +-- [123, "abc", Array(2), {…}] + +function exports.formatDataForPreview(data: Object, showFormattedValue: boolean): string + if data[meta.type] ~= nil then + return (function() + if showFormattedValue then + return data[meta.preview_long] + end + return data[meta.preview_short] + end)() + end + + local type_ = exports.getDataType(data) + + if type_ == "html_element" then + return string.format("<%s />", truncateForDisplay(string.lower(data.tagName))) + elseif type_ == "function" then + return truncateForDisplay(string.format( + "ƒ %s() {}", + (function() + if typeof(data.name) == "function" then + return "" + end + return data.name + end)() + )) + elseif type_ == "string" then + return string.format('"%s"', tostring(data)) + -- ROBLOX TODO? should we support our RegExp and Symbol polyfills here? + -- elseif type_ == 'bigint' then + -- elseif type_ == 'regexp' then + -- elseif type_ == 'symbol' then + elseif type_ == "react_element" then + return string.format("<%s />", truncateForDisplay(exports.getDisplayNameForReactElement(data) or "Unknown")) + -- elseif type_ == 'array_buffer' then + -- elseif type_ == 'data_view' then + elseif type_ == "array" then + local array: Array = data :: any + if showFormattedValue then + local formatted = "" + for i = 1, #array do + if i > 1 then + formatted ..= ", " + end + formatted = formatted .. exports.formatDataForPreview(array[i], false) + if string.len(formatted) > MAX_PREVIEW_STRING_LENGTH then + -- Prevent doing a lot of unnecessary iteration... + break + end + end + return string.format("[%s]", truncateForDisplay(formatted)) + else + local length = (function() + if array[#meta] ~= nil then + return array[#meta] + end + return #array + end)() + return string.format("Array(%s)", length) + end + -- ROBLOX deviation: don't implement web-specifics + -- elseif type_ == 'typed_array' then + -- elseif type_ == 'iterator' then + -- elseif type_ == 'opaque_iterator' then + -- ROBLOX TODO? should we support Luau's datetime object? + -- elseif type_ == 'date' then + elseif type_ == "object" then + if showFormattedValue then + local keys = exports.getAllEnumerableKeys(data) + table.sort(keys, exports.alphaSortKeys) + + local formatted = "" + for i = 1, #keys do + local key = keys[i] :: string + if i > 1 then + formatted = formatted .. ", " + end + formatted = formatted + .. string.format("%s: %s", tostring(key), exports.formatDataForPreview(data[key], false)) + if string.len(formatted) > MAX_PREVIEW_STRING_LENGTH then + -- Prevent doing a lot of unnecessary iteration... + break + end + end + return string.format("{%s}", truncateForDisplay(formatted)) + else + return "{…}" + end + elseif + type_ == "boolean" + or type_ == "number" + or type_ == "infinity" + or type_ == "nan" + or type_ == "null" + or type_ == "undefined" + then + return tostring(data) + else + local ok, result = pcall(truncateForDisplay, "" .. tostring(data)) + return if ok then result else "unserializable" + end +end + +return exports diff --git a/packages/react-devtools-shared/wally.toml b/packages/react-devtools-shared/wally.toml new file mode 100644 index 00000000..bb06e937 --- /dev/null +++ b/packages/react-devtools-shared/wally.toml @@ -0,0 +1,17 @@ +[package] +name = 'core-packages/react-devtools-shared' +description = 'https://github.com/grilme99/CorePackages' +version = '17.0.1-rc.19' +license = 'MIT' +authors = ['Roblox Corporation'] +registry = 'https://github.com/UpliftGames/wally-index' +realm = 'shared' + +[dependencies] +LuauPolyfill = 'core-packages/luau-polyfill@1.2.3' +React = 'core-packages/react@17.0.1-rc.19' +ReactDebugTools = 'core-packages/react-debug-tools@17.0.1-rc.19' +ReactIs = 'core-packages/react-is@17.0.1-rc.19' +ReactReconciler = 'core-packages/react-reconciler@17.0.1-rc.19' +ReactRoblox = 'core-packages/react-roblox@17.0.1-rc.19' +Shared = 'core-packages/shared@17.0.1-rc.19' diff --git a/packages/react-is/default.project.json b/packages/react-is/default.project.json new file mode 100644 index 00000000..b0148b41 --- /dev/null +++ b/packages/react-is/default.project.json @@ -0,0 +1,6 @@ +{ + "name": "react-is", + "tree": { + "$path": "src/" + } +} \ No newline at end of file diff --git a/packages/react-is/src/init.lua b/packages/react-is/src/init.lua new file mode 100644 index 00000000..c7a9409e --- /dev/null +++ b/packages/react-is/src/init.lua @@ -0,0 +1,242 @@ +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.2/packages/react-is/src/ReactIs.js +--[[* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + ]] +local Packages = script.Parent +-- ROBLOX deviation START: not used +-- local LuauPolyfill = require(Packages.LuauPolyfill) +-- local Boolean = LuauPolyfill.Boolean +-- ROBLOX deviation END +-- ROBLOX deviation START: use patched console from shared +-- local console = LuauPolyfill.console +local console = require(Packages.Shared).console +-- ROBLOX deviation END +local exports = {} +-- ROBLOX deviation START: fix import +-- local sharedReactSymbolsModule = require(Packages.shared.ReactSymbols) +local sharedReactSymbolsModule = require(Packages.Shared).ReactSymbols +-- ROBLOX deviation END +local REACT_CONTEXT_TYPE = sharedReactSymbolsModule.REACT_CONTEXT_TYPE +local REACT_ELEMENT_TYPE = sharedReactSymbolsModule.REACT_ELEMENT_TYPE +local REACT_FORWARD_REF_TYPE = sharedReactSymbolsModule.REACT_FORWARD_REF_TYPE +local REACT_FRAGMENT_TYPE = sharedReactSymbolsModule.REACT_FRAGMENT_TYPE +local REACT_LAZY_TYPE = sharedReactSymbolsModule.REACT_LAZY_TYPE +local REACT_MEMO_TYPE = sharedReactSymbolsModule.REACT_MEMO_TYPE +local REACT_PORTAL_TYPE = sharedReactSymbolsModule.REACT_PORTAL_TYPE +local REACT_PROFILER_TYPE = sharedReactSymbolsModule.REACT_PROFILER_TYPE +local REACT_PROVIDER_TYPE = sharedReactSymbolsModule.REACT_PROVIDER_TYPE +local REACT_STRICT_MODE_TYPE = sharedReactSymbolsModule.REACT_STRICT_MODE_TYPE +local REACT_SUSPENSE_TYPE = sharedReactSymbolsModule.REACT_SUSPENSE_TYPE +local REACT_SUSPENSE_LIST_TYPE = sharedReactSymbolsModule.REACT_SUSPENSE_LIST_TYPE +-- ROBLOX deviation START: fix import +-- local isValidElementType = require(Packages.shared.isValidElementType).default +local isValidElementType = require(Packages.Shared).isValidElementType +-- ROBLOX deviation END +-- ROBLOX deviation START: additional imports +local REACT_BINDING_TYPE = sharedReactSymbolsModule.REACT_BINDING_TYPE +-- ROBLOX deviation END +local function typeOf(object: any) + if typeof(object) == "table" and object ~= nil then + local __typeof --[[ ROBLOX CHECK: replaced unhandled characters in identifier. Original identifier: $$typeof ]] = + object["$$typeof"] + -- ROBLOX deviation START: simplified switch statement conversion, adds Binding type check + -- repeat --[[ ROBLOX comment: switch statement conversion ]] + -- local entered_, break_ = false, false + -- local condition_ = __typeof --[[ ROBLOX CHECK: replaced unhandled characters in identifier. Original identifier: $$typeof ]] + -- for _, v in ipairs({ REACT_ELEMENT_TYPE, REACT_PORTAL_TYPE }) do + -- if condition_ == v then + -- if v == REACT_ELEMENT_TYPE then + -- entered_ = true + -- local type_ = object.type + -- local condition_ = type_ + -- if + -- condition_ == REACT_FRAGMENT_TYPE + -- or condition_ == REACT_PROFILER_TYPE + -- or condition_ == REACT_STRICT_MODE_TYPE + -- or condition_ == REACT_SUSPENSE_TYPE + -- or condition_ == REACT_SUSPENSE_LIST_TYPE + -- then + -- return type_ + -- else + -- local __typeofType --[[ ROBLOX CHECK: replaced unhandled characters in identifier. Original identifier: $$typeofType ]] = if Boolean.toJSBoolean( + -- type_ + -- ) + -- then type_["$$typeof"] + -- else type_ + -- local condition_ = __typeofType --[[ ROBLOX CHECK: replaced unhandled characters in identifier. Original identifier: $$typeofType ]] + -- if + -- condition_ == REACT_CONTEXT_TYPE + -- or condition_ == REACT_FORWARD_REF_TYPE + -- or condition_ == REACT_LAZY_TYPE + -- or condition_ == REACT_MEMO_TYPE + -- or condition_ == REACT_PROVIDER_TYPE + -- then + -- return __typeofType --[[ ROBLOX CHECK: replaced unhandled characters in identifier. Original identifier: $$typeofType ]] + -- else + -- return __typeof --[[ ROBLOX CHECK: replaced unhandled characters in identifier. Original identifier: $$typeof ]] + -- end + -- end + -- end + -- if v == REACT_PORTAL_TYPE or entered_ then + -- entered_ = true + -- return __typeof --[[ ROBLOX CHECK: replaced unhandled characters in identifier. Original identifier: $$typeof ]] + -- end + -- end + -- end + -- until true + if __typeof == REACT_ELEMENT_TYPE then + local __type = object.type + + if + __type == REACT_FRAGMENT_TYPE + or __type == REACT_PROFILER_TYPE + or __type == REACT_STRICT_MODE_TYPE + or __type == REACT_SUSPENSE_TYPE + or __type == REACT_SUSPENSE_LIST_TYPE + then + return __type + else + -- ROBLOX note: We need to check that __type is a table before we + -- index into it, or Luau will throw errors + local __typeofType = __type and typeof(__type) == "table" and __type["$$typeof"] + + if + __typeofType == REACT_CONTEXT_TYPE + or __typeofType == REACT_FORWARD_REF_TYPE + or __typeofType == REACT_LAZY_TYPE + or __typeofType == REACT_MEMO_TYPE + or __typeofType == REACT_PROVIDER_TYPE + then + return __typeofType + else + return __typeof + end + end + elseif + __typeof == REACT_PORTAL_TYPE + -- ROBLOX note: Bindings are a feature migrated from Roact + or __typeof == REACT_BINDING_TYPE + then + return __typeof + end + -- ROBLOX deviation END + end + return nil +end +exports.typeOf = typeOf +local ContextConsumer = REACT_CONTEXT_TYPE +exports.ContextConsumer = ContextConsumer +local ContextProvider = REACT_PROVIDER_TYPE +exports.ContextProvider = ContextProvider +local Element = REACT_ELEMENT_TYPE +exports.Element = Element +local ForwardRef = REACT_FORWARD_REF_TYPE +exports.ForwardRef = ForwardRef +local Fragment = REACT_FRAGMENT_TYPE +exports.Fragment = Fragment +local Lazy = REACT_LAZY_TYPE +exports.Lazy = Lazy +local Memo = REACT_MEMO_TYPE +exports.Memo = Memo +local Portal = REACT_PORTAL_TYPE +exports.Portal = Portal +local Profiler = REACT_PROFILER_TYPE +exports.Profiler = Profiler +local StrictMode = REACT_STRICT_MODE_TYPE +exports.StrictMode = StrictMode +local Suspense = REACT_SUSPENSE_TYPE +exports.Suspense = Suspense +-- ROBLOX deviation START: export Roblox Only type +exports.Binding = sharedReactSymbolsModule.REACT_BINDING_TYPE +-- ROBLOX deviation END +exports.isValidElementType = isValidElementType +local hasWarnedAboutDeprecatedIsAsyncMode = false +local hasWarnedAboutDeprecatedIsConcurrentMode = false -- AsyncMode should be deprecated +local function isAsyncMode(object: any) + -- ROBLOX deviation START: remove toJSBoolean, use _G.__DEV__ + -- if Boolean.toJSBoolean(__DEV__) then + -- if not Boolean.toJSBoolean(hasWarnedAboutDeprecatedIsAsyncMode) then + if _G.__DEV__ then + if not hasWarnedAboutDeprecatedIsAsyncMode then + -- ROBLOX deviation END + hasWarnedAboutDeprecatedIsAsyncMode = true -- Using console['warn'] to evade Babel and ESLint + console["warn"]( + "The ReactIs.isAsyncMode() alias has been deprecated, " .. "and will be removed in React 18+." + ) + end + end + return false +end +exports.isAsyncMode = isAsyncMode +local function isConcurrentMode(object: any) + -- ROBLOX deviation START: remove toJSBoolean, use _G.__DEV__ + -- if Boolean.toJSBoolean(__DEV__) then + -- if not Boolean.toJSBoolean(hasWarnedAboutDeprecatedIsConcurrentMode) then + if _G.__DEV__ then + if not hasWarnedAboutDeprecatedIsConcurrentMode then + -- ROBLOX deviation END + hasWarnedAboutDeprecatedIsConcurrentMode = true -- Using console['warn'] to evade Babel and ESLint + console["warn"]( + "The ReactIs.isConcurrentMode() alias has been deprecated, " .. "and will be removed in React 18+." + ) + end + end + return false +end +exports.isConcurrentMode = isConcurrentMode +local function isContextConsumer(object: any) + return typeOf(object) == REACT_CONTEXT_TYPE +end +exports.isContextConsumer = isContextConsumer +local function isContextProvider(object: any) + return typeOf(object) == REACT_PROVIDER_TYPE +end +exports.isContextProvider = isContextProvider +local function isElement(object: any) + return typeof(object) == "table" and object ~= nil and object["$$typeof"] == REACT_ELEMENT_TYPE +end +exports.isElement = isElement +local function isForwardRef(object: any) + return typeOf(object) == REACT_FORWARD_REF_TYPE +end +exports.isForwardRef = isForwardRef +local function isFragment(object: any) + return typeOf(object) == REACT_FRAGMENT_TYPE +end +exports.isFragment = isFragment +local function isLazy(object: any) + return typeOf(object) == REACT_LAZY_TYPE +end +exports.isLazy = isLazy +local function isMemo(object: any) + return typeOf(object) == REACT_MEMO_TYPE +end +exports.isMemo = isMemo +local function isPortal(object: any) + return typeOf(object) == REACT_PORTAL_TYPE +end +exports.isPortal = isPortal +local function isProfiler(object: any) + return typeOf(object) == REACT_PROFILER_TYPE +end +exports.isProfiler = isProfiler +local function isStrictMode(object: any) + return typeOf(object) == REACT_STRICT_MODE_TYPE +end +exports.isStrictMode = isStrictMode +local function isSuspense(object: any) + return typeOf(object) == REACT_SUSPENSE_TYPE +end +exports.isSuspense = isSuspense +-- ROBLOX deviation START: Bindings are a feature migrated from Roact +exports.isBinding = function(object: any) + return typeOf(object) == REACT_BINDING_TYPE +end +-- ROBLOX deviation END +return exports diff --git a/packages/react-is/wally.toml b/packages/react-is/wally.toml new file mode 100644 index 00000000..21667d16 --- /dev/null +++ b/packages/react-is/wally.toml @@ -0,0 +1,11 @@ +[package] +name = 'core-packages/react-is' +description = 'https://github.com/grilme99/CorePackages' +version = '17.0.1-rc.19' +license = 'MIT' +authors = ['Roblox Corporation'] +registry = 'https://github.com/UpliftGames/wally-index' +realm = 'shared' + +[dependencies] +Shared = 'core-packages/shared@17.0.1-rc.19'