diff --git a/default.project.json b/default.project.json index f00ce0ad..484da22d 100644 --- a/default.project.json +++ b/default.project.json @@ -8,6 +8,12 @@ "ReactDebugTools": { "$path": "packages/react-debug-tools/default.project.json" }, + "ReactDevtools": { + "$path": "packages/react-devtools/default.project.json" + }, + "ReactDevtoolsCore": { + "$path": "packages/react-devtools-core/default.project.json" + }, "ReactDevtoolsShared": { "$path": "packages/react-devtools-shared/default.project.json" }, @@ -29,7 +35,10 @@ "Shared": { "$path": "packages/shared/default.project.json" }, - + "MorePolyfill": { + "$path": "packages/more-polyfill/default.project.json" + }, + "_Index": { "$path": "deps/_Index" }, diff --git a/packages/more-polyfill/.luaurc b/packages/more-polyfill/.luaurc new file mode 100644 index 00000000..e2b625c3 --- /dev/null +++ b/packages/more-polyfill/.luaurc @@ -0,0 +1,3 @@ +{ + "languageMode": "strict" +} diff --git a/packages/more-polyfill/README.md b/packages/more-polyfill/README.md new file mode 100644 index 00000000..97175d4b --- /dev/null +++ b/packages/more-polyfill/README.md @@ -0,0 +1,3 @@ +# `more-polyfill` + +This package is meant as an extension to [LuauPolyfill](https://github.com/jsdotlua/luau-polyfill). Content should be moved to LuauPolyfill when possible. diff --git a/packages/more-polyfill/default.project.json b/packages/more-polyfill/default.project.json new file mode 100644 index 00000000..e082022a --- /dev/null +++ b/packages/more-polyfill/default.project.json @@ -0,0 +1,6 @@ +{ + "name": "MorePolyfill", + "tree": { + "$path": "src/" + } +} \ No newline at end of file diff --git a/packages/more-polyfill/src/init.luau b/packages/more-polyfill/src/init.luau new file mode 100644 index 00000000..8e01f162 --- /dev/null +++ b/packages/more-polyfill/src/init.luau @@ -0,0 +1,5 @@ +local JSON = require(script.json) + +return { + JSON = JSON, +} diff --git a/packages/more-polyfill/src/json.luau b/packages/more-polyfill/src/json.luau new file mode 100644 index 00000000..bed72cc4 --- /dev/null +++ b/packages/more-polyfill/src/json.luau @@ -0,0 +1,12 @@ +local HttpService = game:GetService("HttpService") +local JSON = {} + +function JSON.parse(value: string): any + return HttpService:JSONDecode(value) +end + +function JSON.stringify(value: unknown, replacer: nil, space: nil): string + return HttpService:JSONEncode(value) +end + +return JSON diff --git a/packages/more-polyfill/wally.toml b/packages/more-polyfill/wally.toml new file mode 100644 index 00000000..a5e9e452 --- /dev/null +++ b/packages/more-polyfill/wally.toml @@ -0,0 +1,10 @@ +[package] +name = 'jsdotlua/more-polyfill' +description = 'https://github.com/grilme99/CorePackages' +version = '0.1.0' +license = 'MIT' +authors = ['jeparlefrancais '] +registry = 'https://github.com/UpliftGames/wally-index' +realm = 'shared' + +[dependencies] diff --git a/packages/react-debug-tools/wally.toml b/packages/react-debug-tools/wally.toml index c1797d80..88619322 100644 --- a/packages/react-debug-tools/wally.toml +++ b/packages/react-debug-tools/wally.toml @@ -3,7 +3,7 @@ name = 'jsdotlua/react-debug-tools' description = 'https://github.com/grilme99/CorePackages' version = '17.0.2' license = 'MIT' -authors = ['Roblox Corporation'] +authors = ['Facebook, Inc'] registry = 'https://github.com/UpliftGames/wally-index' realm = 'shared' diff --git a/packages/react-devtools-core/default.project.json b/packages/react-devtools-core/default.project.json new file mode 100644 index 00000000..6ce4e159 --- /dev/null +++ b/packages/react-devtools-core/default.project.json @@ -0,0 +1,6 @@ +{ + "name": "react-devtools-core", + "tree": { + "$path": "src/" + } +} \ No newline at end of file diff --git a/packages/react-devtools-core/src/backend.luau b/packages/react-devtools-core/src/backend.luau new file mode 100644 index 00000000..444256fc --- /dev/null +++ b/packages/react-devtools-core/src/backend.luau @@ -0,0 +1,387 @@ +--!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 ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Packages = script.Parent.Parent +local LuauPolyfill = require(Packages.LuauPolyfill) +local Array = LuauPolyfill.Array +-- todo: use console from react shared +local console = LuauPolyfill.console +local setTimeout = LuauPolyfill.setTimeout +type Timeout = LuauPolyfill.Timeout +type Array = LuauPolyfill.Array +type Function = (any) -> any? + +local exports = {} + +local ReactDevtoolsShared = require(Packages.ReactDevtoolsShared) +local Agent = ReactDevtoolsShared.backend.agent +local Bridge = ReactDevtoolsShared.bridge +local installHook = ReactDevtoolsShared.hook.installHook +local initBackend = ReactDevtoolsShared.backend.initBackend +-- import {__DEBUG__} from 'react-devtools-shared/src/constants'; +local __DEBUG__ = false +-- import setupNativeStyleEditor from 'react-devtools-shared/src/backend/NativeStyleEditor/setupNativeStyleEditor'; +-- import {getDefaultComponentFilters} from 'react-devtools-shared/src/utils'; + +-- import type {BackendBridge} from 'react-devtools-shared/src/bridge'; +-- import type {ComponentFilter} from 'react-devtools-shared/src/types'; +-- import type {DevToolsHook} from 'react-devtools-shared/src/backend/types'; +-- import type {ResolveNativeStyle} from 'react-devtools-shared/src/backend/NativeStyleEditor/setupNativeStyleEditor'; + +type ConnectOptions = { + host: string?, + nativeStyleEditorValidAttributes: Array?, + port: number?, + useHttps: boolean?, + resolveRNStyle: ResolveNativeStyle?, + isAppActive: (() -> boolean)?, + websocket: WebSocket?, + -- ... +} + +-- deviation: set global in _G instead of window +-- installHook(window); +installHook(_G) + +local hook: DevToolsHook? = _G.__REACT_DEVTOOLS_GLOBAL_HOOK__ + +-- let savedComponentFilters: Array = getDefaultComponentFilters(); + +local function debug(methodName: string, ...) + if __DEBUG__ then + local args = {} + local totalArgs = select("#", ...) + for i = 1, totalArgs do + table.insert(args, LuauPolyfill.util.inspect((select(i, ...)), { depth = 5 })) + end + console.log(`[core/backend] {methodName}` .. string.rep(" %s", totalArgs), unpack(args)) + end +end + +local function connectToDevTools(options: ConnectOptions?) + if hook == nil then + -- DevTools didn't get injected into this page (maybe b'c of the contentType). + -- todo remove warning + warn("DevTools didn't get injected into this page") + return + end + -- print("Connect to devtools!") + -- local { + -- host = 'localhost', + -- nativeStyleEditorValidAttributes, + -- useHttps = false, + -- port = 8097, + -- websocket, + -- resolveRNStyle = nil, + -- isAppActive = () => true, + -- } = options || {}; + + -- local protocol = if useHttps then 'wss' then 'ws'; + local retryTimeoutID: Timeout | nil = nil + + local function scheduleRetry() + if retryTimeoutID == nil then + -- Two seconds because RN had issues with quick retries. + retryTimeoutID = setTimeout(function() + connectToDevTools(options) + -- deviation: retry more often + end, 1000) + end + end + + -- if (!isAppActive()) { + -- // If the app is in background, maybe retry later. + -- // Don't actually attempt to connect until we're in foreground. + -- scheduleRetry(); + -- return; + -- } + + local bridge: BackendBridge | nil = nil + + local messageListeners: { (any) -> () } = {} + + -- deviation: setup Roblox bindable event + do + local bindableToFrontend: BindableEvent? = ReplicatedStorage:FindFirstChild("ReactDevtoolsFrontendBindable") + local bindableToBackend: BindableEvent? = ReplicatedStorage:FindFirstChild("ReactDevtoolsBackendBindable") + if + bindableToFrontend == nil + or bindableToBackend == nil + or not bindableToFrontend:IsA("BindableEvent") + or not bindableToBackend:IsA("BindableEvent") + then + if bindableToFrontend then + bindableToFrontend:Destroy() + end + if bindableToBackend then + bindableToBackend:Destroy() + end + scheduleRetry() + return + end + + bindableToBackend.Event:Connect(function(data) + -- print("[backend] received message", data) + for _, fn in messageListeners do + local success, err: any = pcall(fn, data) + if not success then + -- jsc doesn't play so well with tracebacks that go into eval'd code, + -- so the stack trace here will stop at the `eval()` call. Getting the + -- message that caused the error is the best we can do for now. + console.log("[React DevTools] Error calling listener", data) + console.log("error:", err) + error(err) + end + end + end) + + -- print("\n\n### backend wait for event on bindableToFrontend\n\n") + while not bindableToFrontend:GetAttribute("Ready") do + bindableToFrontend:GetAttributeChangedSignal("Ready"):Wait() + end + -- print("\n\n===== received ready signal !!!\n\n") + + bridge = Bridge.new({ + listen = function(fn) + table.insert(messageListeners, fn) + return function() + local index = Array.indexOf(messageListeners, fn) + if index > 0 then + Array.splice(messageListeners, index, 1) + end + end + end, + send = function(event: string, payload: any, transferable: Array?) + if __DEBUG__ then + debug("wall.send()", event, payload) + end + + bindableToFrontend:Fire({ event = event, payload = payload }) + end, + }) + end + + -- local uri = protocol + '://' + host + ':' + port; + + -- -- If existing websocket is passed, use it. + -- -- This is necessary to support our custom integrations. + -- -- See D6251744. + -- local ws = websocket ? websocket : new window.WebSocket(uri); + -- ws.onclose = handleClose; + -- ws.onerror = handleFailed; + -- ws.onmessage = handleMessage; + -- ws.onopen = function() { + -- bridge = new Bridge({ + -- listen(fn) { + -- messageListeners.push(fn); + -- return () => { + -- local index = messageListeners.indexOf(fn); + -- if (index >= 0) { + -- messageListeners.splice(index, 1); + -- } + -- }; + -- }, + -- send(event: string, payload: any, transferable?: Array) { + -- if (ws.readyState == ws.OPEN) { + -- if (__DEBUG__) { + -- debug('wall.send()', event, payload); + -- } + + -- ws.send(JSON.stringify({event, payload})); + -- } else { + -- if (__DEBUG__) { + -- debug( + -- 'wall.send()', + -- 'Shutting down bridge because of closed WebSocket connection', + -- ); + -- } + + -- if (bridge !== null) { + -- bridge.shutdown(); + -- } + + -- scheduleRetry(); + -- } + -- }, + -- }); + -- bridge:addListener( + -- 'inspectElement', + -- ({id, rendererID}: {id: number, rendererID: number, ...}) => { + -- local renderer = agent.rendererInterfaces[rendererID]; + -- if (renderer != null) { + -- -- Send event for RN to highlight. + -- local nodes: ?Array = renderer.findNativeNodesForFiberID( + -- id, + -- ); + -- if (nodes != null && nodes[0] != null) { + -- agent.emit('showNativeHighlight', nodes[0]); + -- } + -- } + -- }, + -- ); + -- bridge:addListener( + -- 'updateComponentFilters', + -- (componentFilters: Array) => { + -- -- Save filter changes in memory, in case DevTools is reloaded. + -- -- In that case, the renderer will already be using the updated values. + -- -- We'll lose these in between backend reloads but that can't be helped. + -- savedComponentFilters = componentFilters; + -- }, + -- ); + + -- The renderer interface doesn't read saved component filters directly, + -- because they are generally stored in localStorage within the context of the extension. + -- Because of this it relies on the extension to pass filters. + -- In the case of the standalone DevTools being used with a website, + -- saved filters are injected along with the backend script tag so we shouldn't override them here. + -- This injection strategy doesn't work for React Native though. + -- Ideally the backend would save the filters itself, but RN doesn't provide a sync storage solution. + -- So for now we just fall back to using the default filters... + + -- todo: + -- if window.__REACT_DEVTOOLS_COMPONENT_FILTERS__ == nil then + -- bridge:send("overrideComponentFilters", savedComponentFilters) + -- end + + -- TODO (npm-packages) Warn if "isBackendStorageAPISupported" + local agent = Agent.new(bridge) + agent:addListener("shutdown", function() + -- If we received 'shutdown' from `agent`, we assume the `bridge` is already shutting down, + -- and that caused the 'shutdown' event on the `agent`, so we don't need to call `bridge:shutdown()` here. + hook.emit("shutdown") + end) + + initBackend( + hook, + agent, + -- deviation: use _G instead of window + -- window + _G + ) + + -- Setup React Native style editor if the environment supports it. + -- if (resolveRNStyle != null || hook.resolveRNStyle != null) { + -- setupNativeStyleEditor( + -- bridge, + -- agent, + -- ((resolveRNStyle || hook.resolveRNStyle: any): ResolveNativeStyle), + -- nativeStyleEditorValidAttributes || + -- hook.nativeStyleEditorValidAttributes || + -- null, + -- ); + -- } else { + -- -- Otherwise listen to detect if the environment later supports it. + -- -- For example, Flipper does not eagerly inject these values. + -- -- Instead it relies on the React Native Inspector to lazily inject them. + -- let lazyResolveRNStyle; + -- let lazyNativeStyleEditorValidAttributes; + + -- local initAfterTick = () => { + -- if (bridge !== null) { + -- setupNativeStyleEditor( + -- bridge, + -- agent, + -- lazyResolveRNStyle, + -- lazyNativeStyleEditorValidAttributes, + -- ); + -- } + -- }; + + -- if (!hook.hasOwnProperty('resolveRNStyle')) { + -- Object.defineProperty( + -- hook, + -- 'resolveRNStyle', + -- ({ + -- enumerable: false, + -- get() { + -- return lazyResolveRNStyle; + -- }, + -- set(value) { + -- lazyResolveRNStyle = value; + -- initAfterTick(); + -- }, + -- }: Object), + -- ); + -- } + -- if (!hook.hasOwnProperty('nativeStyleEditorValidAttributes')) { + -- Object.defineProperty( + -- hook, + -- 'nativeStyleEditorValidAttributes', + -- ({ + -- enumerable: false, + -- get() { + -- return lazyNativeStyleEditorValidAttributes; + -- }, + -- set(value) { + -- lazyNativeStyleEditorValidAttributes = value; + -- initAfterTick(); + -- }, + -- }: Object), + -- ); + -- } + -- } + -- }; + + -- local function handleClose() + -- if __DEBUG__ then + -- debug("WebSocket.onclose") + -- end + + -- if bridge ~= nil then + -- bridge.emit("shutdown") + -- end + + -- scheduleRetry() + -- end + + -- local function handleFailed() + -- if __DEBUG__ then + -- debug("WebSocket.onerror") + -- end + + -- scheduleRetry() + -- end + + -- local function handleMessage(event) + -- local data + -- try { + -- if (typeof event.data == 'string') { + -- data = JSON.parse(event.data); + -- if (__DEBUG__) { + -- debug('WebSocket.onmessage', data); + -- } + -- } else { + -- throw Error(); + -- } + -- } catch (e) { + -- console.error( + -- '[React DevTools] Failed to parse JSON: ' + (event.data: any), + -- ); + -- return; + -- } + -- messageListeners.forEach(fn => { + -- try { + -- fn(data); + -- } catch (error) { + -- // jsc doesn't play so well with tracebacks that go into eval'd code, + -- // so the stack trace here will stop at the `eval()` call. Getting the + -- // message that caused the error is the best we can do for now. + -- console.log('[React DevTools] Error calling listener', data); + -- console.log('error:', error); + -- throw error; + -- } + -- }); + -- end +end +exports.connectToDevTools = connectToDevTools + +return exports diff --git a/packages/react-devtools-core/src/init.lua b/packages/react-devtools-core/src/init.lua new file mode 100644 index 00000000..454dd4b6 --- /dev/null +++ b/packages/react-devtools-core/src/init.lua @@ -0,0 +1,4 @@ +--!strict +return { + backend = require(script.backend), +} diff --git a/packages/react-devtools-core/wally.toml b/packages/react-devtools-core/wally.toml new file mode 100644 index 00000000..ac180b39 --- /dev/null +++ b/packages/react-devtools-core/wally.toml @@ -0,0 +1,19 @@ +[package] +name = 'jsdotlua/react-devtools-core' +description = 'https://github.com/grilme99/CorePackages' +version = '17.0.2' +license = 'MIT' +authors = ['Facebook, Inc'] +registry = 'https://github.com/UpliftGames/wally-index' +realm = 'shared' + +[dependencies] +LuauPolyfill = 'jsdotlua/luau-polyfill@1.2.3' +MorePolyfill = 'jsdotlua/more-polyfill@0.1.0' +RegexLua = 'jsdotlua/regex-lua@1.0.2' +React = 'jsdotlua/react@17.0.2' +ReactDebugTools = 'jsdotlua/react-debug-tools@17.0.2' +ReactIs = 'jsdotlua/react-is@17.0.2' +ReactReconciler = 'jsdotlua/react-reconciler@17.0.2' +ReactRoblox = 'jsdotlua/react-roblox@17.0.2' +Shared = 'jsdotlua/shared@17.0.2' diff --git a/packages/react-devtools-shared/src/backend/agent.luau b/packages/react-devtools-shared/src/backend/agent.luau index 2f361cd4..786b035f 100644 --- a/packages/react-devtools-shared/src/backend/agent.luau +++ b/packages/react-devtools-shared/src/backend/agent.luau @@ -65,7 +65,7 @@ type RendererInterface = BackendTypes.RendererInterface local SharedTypes = require(script.Parent.Parent.types) type ComponentFilter = SharedTypes.ComponentFilter -local debug_ = function(methodName, ...) +local function debug_(methodName, ...) if __DEBUG__ then -- deviation: simpler print print(methodName, ...) @@ -517,13 +517,7 @@ 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)() + if recordChangeDescriptions then "true" else "false" ) -- This code path should only be hit if the shell has explicitly told the Store that it supports profiling. diff --git a/packages/react-devtools-shared/src/backend/init.luau b/packages/react-devtools-shared/src/backend/init.luau index 98782818..95be41e1 100644 --- a/packages/react-devtools-shared/src/backend/init.luau +++ b/packages/react-devtools-shared/src/backend/init.luau @@ -24,8 +24,10 @@ 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). + warn("DevTools didn't get injected into this page (maybe b'c of the contentType).") return function() end end + -- print("INIT BACKEND", hook) local subs = { hook.sub("renderer-attached", function(args: { id: number, @@ -56,6 +58,7 @@ local function initBackend(hook: DevToolsHook, agent: Agent, global: Object): () } local attachRenderer = function(id: number, renderer: ReactRenderer) + -- print("attach renderer", id) -- deviation: require attach lazily to avoid the require of renderer causing Roact to initialize prematurely. local attach = require(script.renderer).attach @@ -65,6 +68,7 @@ local function initBackend(hook: DevToolsHook, agent: Agent, global: Object): () if rendererInterface == nil then if type(renderer.findFiberByHostInstance) == "function" then -- react-reconciler v16+ + -- print("attach render with hook!", hook, id, global) rendererInterface = attach(hook, id, renderer, global) elseif renderer.ComponentTree then -- react-dom v15 @@ -93,6 +97,7 @@ local function initBackend(hook: DevToolsHook, agent: Agent, global: Object): () -- Connect renderers that have already injected themselves. hook.renderers:forEach(function(renderer, id) + -- print("attach injected renderer", id) attachRenderer(id, renderer) end) @@ -109,6 +114,7 @@ local function initBackend(hook: DevToolsHook, agent: Agent, global: Object): () hook.emit("react-devtools", agent) hook.reactDevtoolsAgent = agent local function onAgentShutdown() + -- print("onAgentShutdown", onAgentShutdown) Array.forEach(subs, function(fn) fn() end) diff --git a/packages/react-devtools-shared/src/backend/renderer.luau b/packages/react-devtools-shared/src/backend/renderer.luau index 70414f81..47e74b03 100644 --- a/packages/react-devtools-shared/src/backend/renderer.luau +++ b/packages/react-devtools-shared/src/backend/renderer.luau @@ -546,7 +546,7 @@ exports.attach = function( end end - local debug_ = function(name: string, fiber: Fiber, parentFiber: Fiber?): () + local function debug_(name: string, fiber: Fiber, parentFiber: Fiber?): () if __DEBUG__ then -- deviation: Use string nil rather than null as it is Roblox convenion local displayName = getDisplayNameForFiber(fiber) or "nil" @@ -1174,12 +1174,12 @@ exports.attach = function( operations[i + j - 1] = pendingSimulatedUnmountedIDs[j] :: number end - i = i + #pendingSimulatedUnmountedIDs + i += #pendingSimulatedUnmountedIDs -- The root ID should always be unmounted last. if pendingUnmountedRootID ~= nil then operations[i] = pendingUnmountedRootID :: number - i = i + 1 + i += 1 end end @@ -1189,7 +1189,7 @@ exports.attach = function( operations[i + j - 1] = pendingOperations[j] :: number end - i = i + #pendingOperations + i += #pendingOperations -- Let the frontend know about tree operations. -- The first value in this array will identify which root it corresponds to, @@ -3017,6 +3017,7 @@ exports.attach = function( initialIDToRootMap = Map.new(idToRootMap) idToContextsMap = Map.new() + -- print("hook.getFiberRoots(rendererID)", hook.getFiberRoots(rendererID)) hook.getFiberRoots(rendererID):forEach(function(root) local rootID = getFiberID(getPrimaryFiber(root.current)); ((displayNamesByRootID :: any) :: DisplayNamesByRootID):set(rootID, getDisplayNameForRoot(root.current)) diff --git a/packages/react-devtools-shared/src/bridge.luau b/packages/react-devtools-shared/src/bridge.luau index 153465ff..abf1a1f3 100644 --- a/packages/react-devtools-shared/src/bridge.luau +++ b/packages/react-devtools-shared/src/bridge.luau @@ -266,6 +266,7 @@ function Bridge:shutdown() console.warn("Bridge was already shutdown.") return end + -- print("!!! BRIDGE shutdown", debug.traceback("")) -- Queue the shutdown outgoing message for subscribers. self:send("shutdown") diff --git a/packages/react-devtools-shared/src/devtools/ProfilerStore.luau b/packages/react-devtools-shared/src/devtools/ProfilerStore.luau index b8a1cd6a..27812903 100644 --- a/packages/react-devtools-shared/src/devtools/ProfilerStore.luau +++ b/packages/react-devtools-shared/src/devtools/ProfilerStore.luau @@ -75,7 +75,7 @@ function ProfilerStore.new(bridge: FrontendBridge, store: Store, defaultIsProfil if element ~= nil then local snapshotNode: SnapshotNode = { id = elementID, - children = Array.slice(element.children, 0), + children = Array.slice(element.children, 1), displayName = element.displayName, hocDisplayNames = element.hocDisplayNames, key = element.key, @@ -96,6 +96,7 @@ function ProfilerStore.new(bridge: FrontendBridge, store: Store, defaultIsProfil 2 --[[ adapatation: added 1 to array index ]] ] if self._isProfiling then + -- print("onBridgeOperations", operations) local profilingOperations = self._inProgressOperationsByRootID:get(rootID) if profilingOperations == nil then profilingOperations = { operations } @@ -116,7 +117,9 @@ function ProfilerStore.new(bridge: FrontendBridge, store: Store, defaultIsProfil end end function profilerStore:onBridgeProfilingData(dataBackend: ProfilingDataBackend) + -- print("received onBridgeProfilingData", dataBackend) if self._isProfiling then + -- print("ignore profiling data", dataBackend, "because not profiling") -- This should never happen, but if it does- ignore previous profiling data. return end @@ -132,19 +135,21 @@ function ProfilerStore.new(bridge: FrontendBridge, store: Store, defaultIsProfil self._inProgressOperationsByRootID, self._initialSnapshotsByRootID ) - Array.splice(self._dataBackends, 0) + Array.splice(self._dataBackends, 1) 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) + Array.splice(self._dataBackends, 1) self._dataFrontend = nil self._initialRendererIDs:clear() self._initialSnapshotsByRootID:clear() @@ -175,11 +180,14 @@ function ProfilerStore.new(bridge: FrontendBridge, store: Store, defaultIsProfil -- 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) + -- print("PROFILING SESSION FINISHED!", self._dataBackends) + -- print("self._rendererIDsThatReportedProfilingData :") + Array.splice(self._dataBackends, 1) 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 + -- print(" -> rendererID=", rendererID) if not self._rendererQueue:has(rendererID) then self._rendererQueue:add(rendererID) self._bridge:send("getProfilingData", { @@ -256,7 +264,7 @@ function ProfilerStore:profilingData(value: ProfilingDataFrontend | nil): ...Pro console.warn("Profiling data cannot be updated while profiling is in progress.") return end - Array.splice(self._dataBackends, 0) + Array.splice(self._dataBackends, 1) self._dataFrontend = value self._initialRendererIDs:clear() self._initialSnapshotsByRootID:clear() @@ -266,7 +274,7 @@ function ProfilerStore:profilingData(value: ProfilingDataFrontend | nil): ...Pro return end function ProfilerStore:clear(): ...any? - Array.splice(self._dataBackends, 0) + Array.splice(self._dataBackends, 1) self._dataFrontend = nil self._initialRendererIDs:clear() self._initialSnapshotsByRootID:clear() diff --git a/packages/react-devtools-shared/src/devtools/init.luau b/packages/react-devtools-shared/src/devtools/init.luau index 2b4bc145..8db5d492 100644 --- a/packages/react-devtools-shared/src/devtools/init.luau +++ b/packages/react-devtools-shared/src/devtools/init.luau @@ -13,14 +13,18 @@ * limitations under the License. ]] +local store = require(script.store) +export type Store = store.Store + return { utils = require(script.utils), - store = require(script.store), + store = store, cache = require(script.cache), devtools = { Components = { views = { types = require(script.views.Components.types), + DevTools = require(script.views.DevTools), }, }, }, diff --git a/packages/react-devtools-shared/src/devtools/store.luau b/packages/react-devtools-shared/src/devtools/store.luau index f4470875..8dc063a3 100644 --- a/packages/react-devtools-shared/src/devtools/store.luau +++ b/packages/react-devtools-shared/src/devtools/store.luau @@ -56,10 +56,10 @@ local Bridge = require(script.Parent.Parent.bridge) type FrontendBridge = Bridge.FrontendBridge local devtoolsTypes = require(script.Parent.types) -type Store = devtoolsTypes.Store +export type Store = devtoolsTypes.Store type Capabilities = devtoolsTypes.Capabilities -local debug_ = function(methodName, ...) +local function debug_(methodName, ...) if __DEBUG__ then print("Store", methodName, ...) end @@ -161,6 +161,7 @@ function Store.new(bridge: FrontendBridge, config: Config?): Store local isProfiling = false if config ~= nil then + -- print("Dev tools store config:", config) isProfiling = (config :: Config).isProfiling == true local supportsNativeInspection = (config :: Config).supportsNativeInspection @@ -720,12 +721,16 @@ function Store:onBridgeOperations(operations: Array): () -- 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. + -- deviation: 1-indexed means this is 3, not 2 local i = 3 + + -- Reassemble the string table. local stringTable: Array = { -- deviation: element 1 corresponds to empty string "", -- ID = 0 corresponds to the null string. @@ -778,6 +783,7 @@ function Store:onBridgeOperations(operations: Array): () end local supportsProfiling = operations[i] > 0 + -- print("bridge capabilities supportsProfiling",supportsProfiling) i += 1 local hasOwnerMetadata = operations[i] > 0 @@ -808,19 +814,18 @@ function Store:onBridgeOperations(operations: Array): () else parentID = (operations[i] :: any) :: number i += 1 + ownerID = (operations[i] :: any) :: number i += 1 local displayNameStringID = operations[i] -- deviation: 1-indexed local displayName = stringTable[displayNameStringID + 1] - i += 1 local keyStringID = operations[i] -- deviation: 1-indexed local key = stringTable[keyStringID + 1] - i += 1 if __DEBUG__ then @@ -1018,7 +1023,8 @@ function Store:onBridgeOperations(operations: Array): () self._hasOwnerMetadata = false self._supportsProfiling = false - for _, capabilities in self._rootIDToCapabilities do + for _, entry in self._rootIDToCapabilities do + local capabilities = entry[2] local hasOwnerMetadata, supportsProfiling = capabilities.hasOwnerMetadata, capabilities.supportsProfiling if hasOwnerMetadata then diff --git a/packages/react-devtools-shared/src/devtools/views/Components/TreeContext.luau b/packages/react-devtools-shared/src/devtools/views/Components/TreeContext.luau new file mode 100644 index 00000000..9b432454 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Components/TreeContext.luau @@ -0,0 +1,885 @@ +-- upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/devtools/views/Components/TreeContext.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 +-- */ + +-- This context combines tree/selection state, search, and the owners stack. +-- These values are managed together because changes in one often impact the others. +-- Combining them enables us to avoid cascading renders. +-- +-- Changes to search state may impact tree state. +-- For example, updating the selected search result also updates the tree's selected value. +-- Search does not fundamentally change the tree though. +-- It is also possible to update the selected tree value independently. +-- +-- Changes to owners state mask search and tree values. +-- When owners stack is not empty, search is temporarily disabled, +-- and tree values (e.g. num elements, selected element) are masked. +-- Both tree and search values are restored when the owners stack is cleared. +-- +-- For this reason, changes to the tree context are processed in sequence: tree -> search -> owners +-- This enables each section to potentially override (or mask) previous values. + +local Packages = script.Parent.Parent.Parent.Parent.Parent +-- local RegexLua = require(Packages.RegexLua) +type RegExp = any -- RegexLua.RegExp +local LuauPolyfill = require(Packages.LuauPolyfill) +local Error = LuauPolyfill.Error +local Object = LuauPolyfill.Object +local Map = LuauPolyfill.Map +local Array = LuauPolyfill.Array +type Array = LuauPolyfill.Array +type Object = LuauPolyfill.Object +type Map = LuauPolyfill.Map + +local React = require(Packages.React) +local createContext = React.createContext +local useCallback = React.useCallback +local useContext = React.useContext +local useEffect = React.useEffect +local useLayoutEffect = React.useLayoutEffect +local useMemo = React.useMemo +local useReducer = React.useReducer +local useRef = React.useRef + +local Scheduler = require(Packages.Scheduler) +local next = Scheduler.unstable_next +local runWithPriority = Scheduler.unstable_runWithPriority +local UserBlockingPriority = Scheduler.unstable_UserBlockingPriority + +local createRegExp = require(script.Parent.Parent.utils).createRegExp + +local contextModule = require(script.Parent.Parent.context) +local BridgeContext = contextModule.BridgeContext +local StoreContext = contextModule.StoreContext + +-- deviation: take Store type from types file instead +-- local Store = require(script.Parent.Parent.store) +local devtoolsTypes = require(script.Parent.Parent.Parent.types) +type Store = devtoolsTypes.Store + +local types = require(script.Parent.types) +type Element = types.Element + +export type StateContext = { + -- Tree + numElements: number, + ownerSubtreeLeafElementID: number | nil, + selectedElementID: number | nil, + selectedElementIndex: number | nil, + + -- Search + searchIndex: number | nil, + searchResults: Array, + searchText: string, + + -- Owners + ownerID: number | nil, + ownerFlatTree: Array | nil, + + -- Inspection element panel + inspectedElementID: number | nil, +} + +type ACTION_GO_TO_NEXT_SEARCH_RESULT = { + type: "GO_TO_NEXT_SEARCH_RESULT", +} +type ACTION_GO_TO_PREVIOUS_SEARCH_RESULT = { + type: "GO_TO_PREVIOUS_SEARCH_RESULT", +} +type ACTION_HANDLE_STORE_MUTATION = { + type: "HANDLE_STORE_MUTATION", + -- deviation: Luau does not have tuple types + -- payload: [Array, Map], + payload: { Array | Map }, +} +type ACTION_RESET_OWNER_STACK = { + type: "RESET_OWNER_STACK", +} +type ACTION_SELECT_CHILD_ELEMENT_IN_TREE = { + type: "SELECT_CHILD_ELEMENT_IN_TREE", +} +type ACTION_SELECT_ELEMENT_AT_INDEX = { + type: "SELECT_ELEMENT_AT_INDEX", + payload: number | nil, +} +type ACTION_SELECT_ELEMENT_BY_ID = { + type: "SELECT_ELEMENT_BY_ID", + payload: number | nil, +} +type ACTION_SELECT_NEXT_ELEMENT_IN_TREE = { + type: "SELECT_NEXT_ELEMENT_IN_TREE", +} +type ACTION_SELECT_NEXT_SIBLING_IN_TREE = { + type: "SELECT_NEXT_SIBLING_IN_TREE", +} +type ACTION_SELECT_OWNER = { + type: "SELECT_OWNER", + payload: number, +} +type ACTION_SELECT_PARENT_ELEMENT_IN_TREE = { + type: "SELECT_PARENT_ELEMENT_IN_TREE", +} +type ACTION_SELECT_PREVIOUS_ELEMENT_IN_TREE = { + type: "SELECT_PREVIOUS_ELEMENT_IN_TREE", +} +type ACTION_SELECT_PREVIOUS_SIBLING_IN_TREE = { + type: "SELECT_PREVIOUS_SIBLING_IN_TREE", +} +type ACTION_SELECT_OWNER_LIST_NEXT_ELEMENT_IN_TREE = { + type: "SELECT_OWNER_LIST_NEXT_ELEMENT_IN_TREE", +} +type ACTION_SELECT_OWNER_LIST_PREVIOUS_ELEMENT_IN_TREE = { + type: "SELECT_OWNER_LIST_PREVIOUS_ELEMENT_IN_TREE", +} +type ACTION_SET_SEARCH_TEXT = { + type: "SET_SEARCH_TEXT", + payload: string, +} +type ACTION_UPDATE_INSPECTED_ELEMENT_ID = { + type: "UPDATE_INSPECTED_ELEMENT_ID", +} + +type Action = + ACTION_GO_TO_NEXT_SEARCH_RESULT + | ACTION_GO_TO_PREVIOUS_SEARCH_RESULT + | ACTION_HANDLE_STORE_MUTATION + | ACTION_RESET_OWNER_STACK + | ACTION_SELECT_CHILD_ELEMENT_IN_TREE + | ACTION_SELECT_ELEMENT_AT_INDEX + | ACTION_SELECT_ELEMENT_BY_ID + | ACTION_SELECT_NEXT_ELEMENT_IN_TREE + | ACTION_SELECT_NEXT_SIBLING_IN_TREE + | ACTION_SELECT_OWNER + | ACTION_SELECT_PARENT_ELEMENT_IN_TREE + | ACTION_SELECT_PREVIOUS_ELEMENT_IN_TREE + | ACTION_SELECT_PREVIOUS_SIBLING_IN_TREE + | ACTION_SELECT_OWNER_LIST_NEXT_ELEMENT_IN_TREE + | ACTION_SELECT_OWNER_LIST_PREVIOUS_ELEMENT_IN_TREE + | ACTION_SET_SEARCH_TEXT + | ACTION_UPDATE_INSPECTED_ELEMENT_ID + +export type DispatcherContext = (action: Action) -> () + +local TreeStateContext = createContext((nil :: any) :: StateContext) +TreeStateContext.displayName = "TreeStateContext" + +local TreeDispatcherContext = createContext((nil :: any) :: DispatcherContext) +TreeDispatcherContext.displayName = "TreeDispatcherContext" + +type State = { + -- Tree + numElements: number, + ownerSubtreeLeafElementID: number | nil, + selectedElementID: number | nil, + selectedElementIndex: number | nil, + + -- Search + searchIndex: number | nil, + searchResults: Array, + searchText: string, + + -- Owners + ownerID: number | nil, + ownerFlatTree: Array | nil, + + -- Inspection element panel + inspectedElementID: number | nil, +} + +local function reduceTreeState(store: Store, state: State, action: Action): State + local numElements = state.numElements + local ownerSubtreeLeafElementID = state.ownerSubtreeLeafElementID + local selectedElementIndex = state.selectedElementIndex + local selectedElementID = state.selectedElementID + + local ownerID = state.ownerID + + local lookupIDForIndex = true + + -- Base tree should ignore selected element changes when the owner's tree is active. + if ownerID == nil then + local actionType = action.type + if action.type == "HANDLE_STORE_MUTATION" then + numElements = store:getNumElements() + + -- If the currently-selected Element has been removed from the tree, update selection state. + local removedIDs = action.payload[2] :: Map + -- Find the closest parent that wasn't removed during this batch. + -- We deduce the parent-child mapping from removedIDs (id -> parentID) + -- because by now it's too late to read them from the store. + while selectedElementID ~= nil and removedIDs:has(selectedElementID) do + selectedElementID = (removedIDs:get(selectedElementID) :: any) :: number + end + if selectedElementID == 1 then + -- The whole root was removed. + selectedElementIndex = nil + end + elseif actionType == "SELECT_CHILD_ELEMENT_IN_TREE" then + ownerSubtreeLeafElementID = nil + + if selectedElementIndex ~= nil then + local selectedElement = store:getElementAtIndex((selectedElementIndex :: any) :: number) + if selectedElement ~= nil and #selectedElement.children > 0 and not selectedElement.isCollapsed then + local firstChildID = selectedElement.children[1] + local firstChildIndex = store:getIndexOfElementID(firstChildID) + if firstChildIndex ~= nil then + selectedElementIndex = firstChildIndex + end + end + end + elseif actionType == "SELECT_ELEMENT_AT_INDEX" then + ownerSubtreeLeafElementID = nil + + selectedElementIndex = (action :: ACTION_SELECT_ELEMENT_AT_INDEX).payload + elseif actionType == "SELECT_ELEMENT_BY_ID" then + ownerSubtreeLeafElementID = nil + + -- Skip lookup in this case; it would be redundant. + -- It might also cause problems if the specified element was inside of a (not yet expanded) subtree. + lookupIDForIndex = false + + selectedElementID = (action :: ACTION_SELECT_ELEMENT_BY_ID).payload + selectedElementIndex = if selectedElementID == nil + then nil + else store:getIndexOfElementID(selectedElementID) + elseif actionType == "SELECT_NEXT_ELEMENT_IN_TREE" then + ownerSubtreeLeafElementID = nil + + if + selectedElementIndex == nil + -- deviation: Luau is 1-index based, so remove +1 + or selectedElementIndex >= numElements + then + selectedElementIndex = 1 + else + selectedElementIndex += 1 + end + elseif actionType == "SELECT_NEXT_SIBLING_IN_TREE" then + ownerSubtreeLeafElementID = nil + + if selectedElementIndex ~= nil then + local selectedElement = store:getElementAtIndex((selectedElementIndex :: any) :: number) + if selectedElement ~= nil and selectedElement.parentID ~= 0 then + local parent = store:getElementByID(selectedElement.parentID) + if parent ~= nil then + local children = parent.children + local selectedChildIndex = Array.indexOf(children, selectedElement.id) + -- deviation: remove -1 because 1-indexing + local nextChildID = if selectedChildIndex < #children + then children[selectedChildIndex + 1] + -- deviation: use children[1] because 1-indexing + else children[1] + selectedElementIndex = store:getIndexOfElementID(nextChildID) + end + end + end + elseif actionType == "SELECT_OWNER_LIST_NEXT_ELEMENT_IN_TREE" then + if selectedElementIndex ~= nil then + if ownerSubtreeLeafElementID ~= nil and ownerSubtreeLeafElementID ~= selectedElementID then + local leafElement = store:getElementByID(ownerSubtreeLeafElementID) + if leafElement ~= nil then + local currentElement = leafElement + while currentElement ~= nil do + if currentElement.ownerID == selectedElementID then + selectedElementIndex = store:getIndexOfElementID(currentElement.id) + break + elseif currentElement.ownerID ~= 0 then + currentElement = store:getElementByID(currentElement.ownerID) + end + end + end + end + end + elseif actionType == "SELECT_OWNER_LIST_PREVIOUS_ELEMENT_IN_TREE" then + if selectedElementIndex ~= nil then + if ownerSubtreeLeafElementID == nil then + -- If this is the first time we're stepping through the owners tree, + -- pin the current component as the owners list leaf. + -- This will enable us to step back down to this component. + ownerSubtreeLeafElementID = selectedElementID + end + + local selectedElement = store:getElementAtIndex((selectedElementIndex :: any) :: number) + if selectedElement ~= nil and selectedElement.ownerID ~= 0 then + local ownerIndex = store:getIndexOfElementID(selectedElement.ownerID) + if ownerIndex ~= nil then + selectedElementIndex = ownerIndex + end + end + end + elseif actionType == "SELECT_PARENT_ELEMENT_IN_TREE" then + ownerSubtreeLeafElementID = nil + + if selectedElementIndex ~= nil then + local selectedElement = store:getElementAtIndex((selectedElementIndex :: any) :: number) + if selectedElement ~= nil and selectedElement.parentID ~= 0 then + local parentIndex = store:getIndexOfElementID(selectedElement.parentID) + if parentIndex ~= nil then + selectedElementIndex = parentIndex + end + end + end + elseif actionType == "SELECT_PREVIOUS_ELEMENT_IN_TREE" then + ownerSubtreeLeafElementID = nil + + if selectedElementIndex == nil or selectedElementIndex == 1 then + -- deviation: remove -1 to point to the last element (because of 1-indexing) + selectedElementIndex = numElements + else + selectedElementIndex -= 1 + end + elseif actionType == "SELECT_PREVIOUS_SIBLING_IN_TREE" then + ownerSubtreeLeafElementID = nil + + if selectedElementIndex ~= nil then + local selectedElement = store:getElementAtIndex((selectedElementIndex :: any) :: number) + if selectedElement ~= nil and selectedElement.parentID ~= 0 then + local parent = store:getElementByID(selectedElement.parentID) + if parent ~= nil then + local children = parent.children + local selectedChildIndex = Array.indexOf(children, selectedElement.id) + local nextChildID = if selectedChildIndex > 0 + then children[selectedChildIndex - 1] + else children[#children] + selectedElementIndex = store:getIndexOfElementID(nextChildID) + end + end + end + else + -- React can bailout of no-op updates. + return state + end + end + + -- Keep selected item ID and index in sync. + if lookupIDForIndex and selectedElementIndex ~= state.selectedElementIndex then + if selectedElementIndex == nil then + selectedElementID = nil + else + selectedElementID = store:getElementIDAtIndex((selectedElementIndex :: any) :: number) + end + end + + return Object.assign(table.clone(state), { + numElements = numElements, + ownerSubtreeLeafElementID = ownerSubtreeLeafElementID, + selectedElementIndex = selectedElementIndex, + selectedElementID = selectedElementID, + }) +end + +-- deviation: pre-declaration +local getNearestResultIndex +local recursivelySearchTree + +local function reduceSearchState(store: Store, state: State, action: Action): State + local searchIndex = state.searchIndex + local searchResults = state.searchResults + local searchText = state.searchText + local selectedElementID = state.selectedElementID + local selectedElementIndex = state.selectedElementIndex + local ownerID = state.ownerID + + local prevSearchIndex = searchIndex + local prevSearchText = searchText + local numPrevSearchResults = #searchResults + + -- We track explicitly whether search was requested because + -- we might want to search even if search index didn't change. + -- For example, if you press "next result" on a search with a single + -- result but a different current selection, we'll set this to true. + local didRequestSearch = false + + -- Search isn't supported when the owner's tree is active. + if ownerID == nil then + local actionType = action.type + if actionType == "GO_TO_NEXT_SEARCH_RESULT" then + if numPrevSearchResults > 0 then + didRequestSearch = true + searchIndex = if searchIndex + 1 < numPrevSearchResults then searchIndex + 1 else 0 + end + elseif actionType == "GO_TO_PREVIOUS_SEARCH_RESULT" then + if numPrevSearchResults > 0 then + didRequestSearch = true + searchIndex = if (searchIndex :: any) :: number > 0 + then ((searchIndex :: any) :: number) - 1 + else numPrevSearchResults - 1 + end + elseif actionType == "HANDLE_STORE_MUTATION" then + if searchText ~= "" then + local actionPayload = (action :: ACTION_HANDLE_STORE_MUTATION).payload + local addedElementIDs = actionPayload[1] :: Array + local removedElementIDs = actionPayload[2] :: Map + + removedElementIDs:forEach(function(parentID, id) + -- Prune this item from the search results. + local index = Array.indexOf(searchResults, id) + if index >= 0 then + searchResults = + -- deviation: use 1-indexing + Array.concat(Array.slice(searchResults, 1, index), Array.slice(searchResults, index + 1)) + + -- If the results are now empty, also deselect things. + if #searchResults == 0 then + searchIndex = nil + elseif ((searchIndex :: any) :: number) >= #searchResults then + -- deviation: use 1-indexing, so remove the `-1` + searchIndex = #searchResults -- -1 + end + end + end) + + Array.forEach(addedElementIDs, function(id) + local element = (store:getElementByID(id) :: any) :: Element + + -- It's possible that multiple tree operations will fire before this action has run. + -- So it's important to check for elements that may have been added and then removed. + if element ~= nil then + local displayName = element.displayName + + -- Add this item to the search results if it matches. + local regExp = createRegExp(searchText) + if displayName ~= nil and regExp.test(displayName) then + local newElementIndex = (store:getIndexOfElementID(id) :: any) :: number + + local foundMatch = false + for index, resultID in searchResults do + if newElementIndex < ((store:getIndexOfElementID(resultID) :: any) :: number) then + foundMatch = true + searchResults = Array.concat( + -- deviation: use 1-indexing + Array.concat(Array.slice(searchResults, 1, index), resultID), + Array.slice(searchResults, index) + ) + break + end + end + if not foundMatch then + searchResults = Array.concat(searchResults, id) + end + + searchIndex = if searchIndex == nil then 0 else searchIndex + end + end + end) + end + elseif actionType == "SET_SEARCH_TEXT" then + searchIndex = nil + searchResults = {} + searchText = (action :: ACTION_SET_SEARCH_TEXT).payload + + if searchText ~= "" then + local regExp = createRegExp(searchText) + store.roots.forEach(function(rootID) + recursivelySearchTree(store, rootID, regExp, searchResults) + end) + if #searchResults > 0 then + if prevSearchIndex == nil then + if selectedElementIndex ~= nil then + searchIndex = getNearestResultIndex(store, searchResults, selectedElementIndex) + else + searchIndex = 0 + end + else + searchIndex = math.min( + (prevSearchIndex :: any) :: number, + -- deviation: use 1-indexing + #searchResults -- - 1 + ) + end + end + end + else + -- React can bailout of no-op updates. + return state + end + end + + if searchText ~= prevSearchText then + local newSearchIndex = Array.indexOf(searchResults, selectedElementID) + if newSearchIndex == -1 then + -- Only move the selection if the new query + -- doesn't match the current selection anymore. + didRequestSearch = true + else + -- Selected item still matches the new search query. + -- Adjust the index to reflect its position in new results. + searchIndex = newSearchIndex + end + end + if didRequestSearch and searchIndex ~= nil then + selectedElementID = (searchResults[searchIndex] :: any) :: number + selectedElementIndex = store:getIndexOfElementID((selectedElementID :: any) :: number) + end + + return Object.assign(table.clone(state), { + + selectedElementID = selectedElementID, + selectedElementIndex = selectedElementIndex, + + searchIndex = searchIndex, + searchResults = searchResults, + searchText = searchText, + }) +end + +local function reduceOwnersState(store: Store, state: State, action: Action): State + local numElements = state.numElements + local selectedElementID = state.selectedElementID + local selectedElementIndex = state.selectedElementIndex + local ownerID = state.ownerID + local ownerFlatTree = state.ownerFlatTree + local searchIndex = state.searchIndex + local searchResults = state.searchResults + local searchText = state.searchText + + local prevSelectedElementIndex = selectedElementIndex + + local actionType = action.type + if actionType == "HANDLE_STORE_MUTATION" then + if ownerID ~= nil then + if not store:containsElement(ownerID) then + ownerID = nil + ownerFlatTree = nil + selectedElementID = nil + else + ownerFlatTree = store:getOwnersListForElement(ownerID) + if selectedElementID ~= nil then + -- Mutation might have caused the index of this ID to shift. + selectedElementIndex = Array.findIndex(ownerFlatTree, function(element) + return element.id == selectedElementID + end) + end + end + else + if selectedElementID ~= nil then + -- Mutation might have caused the index of this ID to shift. + selectedElementIndex = store:getIndexOfElementID(selectedElementID) + end + end + if selectedElementIndex == -1 then + -- If we couldn't find this ID after mutation, unselect it. + selectedElementIndex = nil + selectedElementID = nil + end + elseif actionType == "RESET_OWNER_STACK" then + ownerID = nil + ownerFlatTree = nil + selectedElementIndex = if selectedElementID ~= nil then store:getIndexOfElementID(selectedElementID) else nil + elseif actionType == "SELECT_ELEMENT_AT_INDEX" then + if ownerFlatTree ~= nil then + selectedElementIndex = (action :: ACTION_SELECT_ELEMENT_AT_INDEX).payload + end + elseif actionType == "SELECT_ELEMENT_BY_ID" then + if ownerFlatTree ~= nil then + local payload = (action :: ACTION_SELECT_ELEMENT_BY_ID).payload + if payload == nil then + selectedElementIndex = nil + else + selectedElementIndex = ownerFlatTree.findIndex(function(element) + return element.id == payload + end) + + -- If the selected element is outside of the current owners list, + -- exit the list and select the element in the main tree. + -- This supports features like toggling Suspense. + if selectedElementIndex ~= nil and selectedElementIndex < 1 then + ownerID = nil + ownerFlatTree = nil + selectedElementIndex = store:getIndexOfElementID(payload) + end + end + end + elseif actionType == "SELECT_NEXT_ELEMENT_IN_TREE" then + if ownerFlatTree ~= nil and #ownerFlatTree > 0 then + if selectedElementIndex == nil then + -- deviation: use 1 because 1-indexing + selectedElementIndex = 1 + -- deviation: remove +1 because 1-indexing + elseif selectedElementIndex < #ownerFlatTree then + selectedElementIndex += 1 + end + end + elseif actionType == "SELECT_PREVIOUS_ELEMENT_IN_TREE" then + if ownerFlatTree ~= nil and #ownerFlatTree > 0 then + -- deviation: use 1 because 1-indexing + if selectedElementIndex ~= nil and selectedElementIndex > 1 then + selectedElementIndex -= 1 + end + end + elseif actionType == "SELECT_OWNER" then + -- If the Store doesn't have any owners metadata, don't drill into an empty stack. + -- This is a confusing user experience. + if store.hasOwnerMetadata then + ownerID = (action :: ACTION_SELECT_OWNER).payload + ownerFlatTree = store:getOwnersListForElement(ownerID :: number) + + -- Always force reset selection to be the top of the new owner tree. + selectedElementIndex = 1 + prevSelectedElementIndex = nil + end + else + -- React can bailout of no-op updates. + return state + end + + -- -- Changes in the selected owner require re-calculating the owners tree. + if ownerFlatTree ~= state.ownerFlatTree or action.type == "HANDLE_STORE_MUTATION" then + if ownerFlatTree == nil then + numElements = store:getNumElements() + else + numElements = #ownerFlatTree + end + end + + -- Keep selected item ID and index in sync. + if selectedElementIndex ~= prevSelectedElementIndex then + if selectedElementIndex == nil then + selectedElementID = nil + else + if ownerFlatTree ~= nil then + selectedElementID = ownerFlatTree[selectedElementIndex].id + end + end + end + + return Object.assign(table.clone(state), { + numElements = numElements, + selectedElementID = selectedElementID, + selectedElementIndex = selectedElementIndex, + + searchIndex = searchIndex, + searchResults = searchResults, + searchText = searchText, + + ownerID = ownerID, + ownerFlatTree = ownerFlatTree, + }) +end + +local function reduceSuspenseState(store: Store, state: State, action: Action): State + local type = action.type + if type == "UPDATE_INSPECTED_ELEMENT_ID" then + if state.inspectedElementID ~= state.selectedElementID then + return Object.assign(table.clone(state), { + inspectedElementID = state.selectedElementID, + }) + end + end + -- React can bailout of no-op updates. + return state +end + +type Props = { + children: React.ReactNode, + + -- Used for automated testing + defaultInspectedElementID: number?, + defaultOwnerID: number?, + defaultSelectedElementID: number?, + defaultSelectedElementIndex: number?, +} + +-- -- TODO Remove TreeContextController wrapper element once global ConsearchText.write API exists. +local function TreeContextController(props: Props) + local children = props.children + local defaultInspectedElementID = props.defaultInspectedElementID + local defaultOwnerID = props.defaultOwnerID + local defaultSelectedElementID = props.defaultSelectedElementID + local defaultSelectedElementIndex = props.defaultSelectedElementIndex + + local bridge = useContext(BridgeContext) + local store = useContext(StoreContext) + + local initialRevision = useMemo(function() + return store.revision + end, { store }) + + -- This reducer is created inline because it needs access to the Store. + -- The store is mutable, but the Store itself is global and lives for the lifetime of the DevTools, + -- so it's okay for the reducer to have an empty dependencies array. + local reducer = useMemo(function() + return function(state: State, action: Action): State + local type = action.type + if + type == "GO_TO_NEXT_SEARCH_RESULT" + or type == "GO_TO_PREVIOUS_SEARCH_RESULT" + or type == "HANDLE_STORE_MUTATION" + or type == "RESET_OWNER_STACK" + or type == "SELECT_ELEMENT_AT_INDEX" + or type == "SELECT_ELEMENT_BY_ID" + or type == "SELECT_CHILD_ELEMENT_IN_TREE" + or type == "SELECT_NEXT_ELEMENT_IN_TREE" + or type == "SELECT_NEXT_SIBLING_IN_TREE" + or type == "SELECT_OWNER_LIST_NEXT_ELEMENT_IN_TREE" + or type == "SELECT_OWNER_LIST_PREVIOUS_ELEMENT_IN_TREE" + or type == "SELECT_PARENT_ELEMENT_IN_TREE" + or type == "SELECT_PREVIOUS_ELEMENT_IN_TREE" + or type == "SELECT_PREVIOUS_SIBLING_IN_TREE" + or type == "SELECT_OWNER" + or type == "UPDATE_INSPECTED_ELEMENT_ID" + or type == "SET_SEARCH_TEXT" + then + state = reduceTreeState(store, state, action) + state = reduceSearchState(store, state, action) + state = reduceOwnersState(store, state, action) + state = reduceSuspenseState(store, state, action) + + -- If the selected ID is in a collapsed subtree, reset the selected index to null. + -- We'll know the correct index after the layout effect will toggle the tree, + -- and the store tree is mutated to account for that. + if state.selectedElementID ~= nil and store:isInsideCollapsedSubTree(state.selectedElementID) then + local nextState = table.clone(state) + nextState.selectedElementIndex = nil + return nextState + end + + return state + else + error(Error.new(`Unrecognized action "${type}"`)) + end + end + end, { store }) + + local state, dispatch = useReducer(reducer, { + -- Tree + numElements = store:getNumElements(), + ownerSubtreeLeafElementID = nil, + selectedElementID = if defaultSelectedElementID == nil then nil else defaultSelectedElementID, + selectedElementIndex = if defaultSelectedElementIndex == nil then nil else defaultSelectedElementIndex, + + -- Search + searchIndex = nil, + searchResults = {}, + searchText = "", + + -- Owners + ownerID = if defaultOwnerID == nil then nil else defaultOwnerID, + ownerFlatTree = nil, + + -- Inspection element panel + inspectedElementID = if defaultInspectedElementID == nil then nil else defaultInspectedElementID, + }) + + local dispatchWrapper = useCallback(function(action: Action) + -- Run the first update at "user-blocking" priority in case dispatch is called from a non-React event. + -- In this case, the current (and "next") priorities would both be "normal", + -- and suspense would potentially block both updates. + runWithPriority(UserBlockingPriority, function() + return dispatch(action) + end) + next(function() + return dispatch({ type = "UPDATE_INSPECTED_ELEMENT_ID" }) + end) + end, { dispatch }) + + -- Listen for host element selections. + useEffect(function() + local handleSelectFiber = function(id: number) + dispatchWrapper({ type = "SELECT_ELEMENT_BY_ID", payload = id }) + end + bridge:addListener("selectFiber", handleSelectFiber) + return function() + return bridge:removeListener("selectFiber", handleSelectFiber) + end + end, { bridge::any, dispatchWrapper }) + + -- If a newly-selected search result or inspection selection is inside of a collapsed subtree, auto expand it. + -- This needs to be a layout effect to avoid temporarily flashing an incorrect selection. + local prevSelectedElementID = useRef(nil :: number | nil) + useLayoutEffect(function() + if state.selectedElementID ~= prevSelectedElementID.current then + prevSelectedElementID.current = state.selectedElementID + + if state.selectedElementID ~= nil then + local element = store:getElementByID(state.selectedElementID) + if element ~= nil and element.parentID > 0 then + store:toggleIsCollapsed(element.parentID, false) + end + end + end + end, { state.selectedElementID :: any, store }) + + -- Mutations to the underlying tree may impact this context (e.g. search results, selection state). + useEffect(function() + -- deviation: Luau does not have tuple types + -- [ Array, Map ] + local handleStoreMutated = function(payload: { Array | Map }) + local addedElementIDs = payload[1] + local removedElementIDs = payload[2] + dispatchWrapper({ + type = "HANDLE_STORE_MUTATION", + payload = { addedElementIDs, removedElementIDs }, + }) + end + + -- Since this is a passive effect, the tree may have been mutated before our initial subscription. + if store.revision ~= initialRevision then + -- At the moment, we can treat this as a mutation. + -- We don't know which Elements were newly added/removed, but that should be okay in this case. + -- It would only impact the search state, which is unlikely to exist yet at this point. + dispatchWrapper({ + type = "HANDLE_STORE_MUTATION", + payload = { {}, Map.new() }, + }) + end + store:addListener("mutated", handleStoreMutated) + + return function() + return store:removeListener("mutated", handleStoreMutated) + end + end, { dispatchWrapper :: any, initialRevision, store }) + + return React.createElement( + TreeStateContext.Provider, + { value = state }, + React.createElement(TreeDispatcherContext.Provider, { + value = dispatchWrapper, + }, children) + ) +end + +function recursivelySearchTree(store: Store, elementID: number, regExp: RegExp, searchResults: Array): () + local element = (store:getElementByID(elementID) :: any) :: Element + local children = element.children + local displayName = element.displayName + local hocDisplayNames = element.hocDisplayNames + + if displayName ~= nil and regExp.test(displayName) == true then + table.insert(searchResults, elementID) + elseif + hocDisplayNames ~= nil + and #hocDisplayNames > 0 + and Array.some(hocDisplayNames, function(name) + return regExp:test(name) + end) == true + then + table.insert(searchResults, elementID) + end + + Array.forEach(children, function(childID) + return recursivelySearchTree(store, childID, regExp, searchResults) + end) +end + +function getNearestResultIndex(store: Store, searchResults: Array, selectedElementIndex: number): number + local index = Array.findIndex(searchResults, function(id) + local innerIndex = store:getIndexOfElementID(id) + return innerIndex ~= nil and innerIndex >= selectedElementIndex + end) + + -- deviation: use 1-indexing + return if index == -1 then 1 else index +end + +return { + TreeDispatcherContext = TreeDispatcherContext, + TreeStateContext = TreeStateContext, + TreeContextController = TreeContextController, +} diff --git a/packages/react-devtools-shared/src/devtools/views/DevTools.luau b/packages/react-devtools-shared/src/devtools/views/DevTools.luau new file mode 100644 index 00000000..66523662 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/DevTools.luau @@ -0,0 +1,304 @@ +--!strict +-- upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/devtools/views/DevTools.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 +local LuauPolyfill = require(Packages.LuauPolyfill) +type Array = LuauPolyfill.Array +local exports = {} + +local React = require(Packages.React) +local useEffect = React.useEffect +local useLayoutEffect = React.useLayoutEffect +local useMemo = React.useMemo +local useRef = React.useRef + +local Div = require(script.Parent.roblox.Div) +local Text = require(script.Parent.roblox.Text) + +local Store = require(script.Parent.Parent.store) +type Store = Store.Store +local contextModule = require(script.Parent.context) +local BridgeContext = contextModule.BridgeContext +local ContextMenuContext = contextModule.ContextMenuContext +local StoreContext = contextModule.StoreContext +-- local Components from './Components/Components'; +local Profiler = require(script.Parent.Profiler.Profiler).default +local TabBarModule = require(script.Parent.TabBar) +local TabBar = TabBarModule.TabBar + +local SettingsModal = require(script.Parent.Settings.SettingsModal).default +local SettingsModalContextModule = require(script.Parent.Settings.SettingsModalContext) +local SettingsModalContextController = SettingsModalContextModule.SettingsModalContextController + +local SettingsContextController = require(script.Parent.Settings.SettingsContext).SettingsContextController +local TreeContext = require(script.Parent.Components.TreeContext) +local TreeContextController = TreeContext.TreeContextController +-- local ViewElementSourceContext from './Components/ViewElementSourceContext'; +local ProfilerContext = require(script.Parent.Profiler.ProfilerContext) +local ProfilerContextController = ProfilerContext.ProfilerContextController +-- local ModalDialogContextController = require(script.Parent.ModalDialog).ModalDialogContextController +-- local ReactLogo from './ReactLogo'; +-- local UnsupportedVersionDialog from './UnsupportedVersionDialog'; +-- local WarnIfLegacyBackendDetected from './WarnIfLegacyBackendDetected'; +local hooksModule = require(script.Parent.hooks) +local useLocalStorage = hooksModule.useLocalStorage + +-- import styles from './DevTools.css'; + +-- import './root.css'; + +-- import type {InspectedElement} from 'react-devtools-shared/src/devtools/views/Components/types'; +type InspectedElement = any + +local bridgeModule = require(script.Parent.Parent.Parent.bridge) +type FrontendBridge = bridgeModule.FrontendBridge + +export type BrowserTheme = "dark" | "light" +export type TabID = "components" | "profiler" +export type ViewElementSource = (id: number, inspectedElement: InspectedElement) -> () +export type ViewAttributeSource = (id: number, path: Array) -> () +export type CanViewElementSource = (inspectedElement: InspectedElement) -> boolean + +export type Props = { + bridge: FrontendBridge, + browserTheme: BrowserTheme?, + canViewElementSourceFunction: CanViewElementSource?, + defaultTab: TabID?, + enabledInspectedElementContextMenu: boolean?, + showTabBar: boolean?, + store: Store, + warnIfLegacyBackendDetected: boolean?, + warnIfUnsupportedVersionDetected: boolean?, + viewAttributeSourceFunction: ViewAttributeSource?, + viewElementSourceFunction: ViewElementSource?, + + -- This property is used only by the web extension target. + -- The built-in tab UI is hidden in that case, in favor of the browser's own panel tabs. + -- This is done to save space within the app. + -- Because of this, the extension needs to be able to change which tab is active/rendered. + overrideTab: TabID?, + + -- To avoid potential multi-root trickiness, the web extension uses portals to render tabs. + -- The root app is rendered in the top-level extension window, + -- but individual tabs (e.g. Components, Profiling) can be rendered into portals within their browser panels. + componentsPortalContainer: Element?, + profilerPortalContainer: Element?, +} + +local componentsTab = { + id = "components" :: TabID, + -- icon = "components", + label = "Components", + title = "React Components", +} :: TabBarModule.TabInfo +local profilerTab = { + id = "profiler" :: TabID, + -- icon = "profiler", + label = "Profiler", + title = "React Profiler", +} :: TabBarModule.TabInfo + +local tabs: Array = { componentsTab, profilerTab } + +local BAR_HEIGHT = 40 + +local function DevTools(props: Props) + local bridge = props.bridge + local browserTheme = if props.browserTheme == nil then "light" else props.browserTheme + local canViewElementSourceFunction = props.canViewElementSourceFunction + local componentsPortalContainer = props.componentsPortalContainer + local defaultTab = if props.defaultTab == nil then "components" else props.defaultTab + local enabledInspectedElementContextMenu = if props.enabledInspectedElementContextMenu == nil + then false + else props.enabledInspectedElementContextMenu + local overrideTab = props.overrideTab + local profilerPortalContainer = props.profilerPortalContainer + local showTabBar = if props.showTabBar == nil then false else props.showTabBar + local store = props.store + local warnIfLegacyBackendDetected = if props.warnIfLegacyBackendDetected == nil + then false + else props.warnIfLegacyBackendDetected + local warnIfUnsupportedVersionDetected = if props.warnIfUnsupportedVersionDetected == nil + then false + else props.warnIfUnsupportedVersionDetected + local viewAttributeSourceFunction = props.viewAttributeSourceFunction + local viewElementSourceFunction = props.viewElementSourceFunction + + local currentTab, setTab = useLocalStorage("React::DevTools::defaultTab", defaultTab) + + local tab = currentTab + + if overrideTab ~= nil then + tab = overrideTab + end + + local viewElementSource = useMemo(function() + return { + canViewElementSourceFunction = canViewElementSourceFunction, + viewElementSourceFunction = viewElementSourceFunction, + } + end, { canViewElementSourceFunction :: any, viewElementSourceFunction }) + + local contextMenu = useMemo(function() + return { + isEnabledForInspectedElement = enabledInspectedElementContextMenu, + viewAttributeSourceFunction = viewAttributeSourceFunction, + } + end, { enabledInspectedElementContextMenu :: any, viewAttributeSourceFunction }) + + local devToolsRef = useRef(nil :: HTMLElement | nil) + + -- useEffect(function() + -- if not showTabBar then + -- return + -- end + + -- local div = devToolsRef.current + -- if div == nil then + -- return + -- end + + -- local ownerWindow = div.ownerDocument.defaultView + -- local handleKeyDown = function(event: KeyboardEvent) + -- if event.ctrlKey or event.metaKey then + -- if event.key == "1" then + -- setTab(tabs[0].id) + -- event.preventDefault() + -- event.stopPropagation() + -- elseif event.key == "2" then + -- setTab(tabs[1].id) + -- event.preventDefault() + -- event.stopPropagation() + -- end + -- end + -- end + + -- ownerWindow:addEventListener("keydown", handleKeyDown) + -- return function() + -- ownerWindow:removeEventListener("keydown", handleKeyDown) + -- end + -- end, { showTabBar }) + + useLayoutEffect(function() + return function() + local success, err = pcall(function() + bridge:shutdown() + end) + if not success then + -- Attempting to use a disconnected port. + warn("Attempting to use a disconnected port: " .. tostring(err)) + end + end + end, { bridge }) + + return React.createElement( + BridgeContext.Provider, + { value = bridge }, + React.createElement( + StoreContext.Provider, + { value = store }, + React.createElement( + ContextMenuContext.Provider, + { value = contextMenu }, + -- React.createElement( + -- ModalDialogContextController, + -- {}, + React.createElement( + SettingsModalContextController, + {}, + React.createElement( + SettingsContextController, + { + browserTheme = browserTheme, + componentsPortalContainer = componentsPortalContainer, + profilerPortalContainer = profilerPortalContainer, + }, + -- React.createElement( + -- ViewElementSourceContext.Provider, + -- { value = viewElementSource }, + React.createElement( + TreeContextController, + nil, + React.createElement( + ProfilerContextController, + nil, + React.createElement(Div, { + ref = devToolsRef, + }, showTabBar and React.createElement( + Div, + { + name = "tab-bar-container", + direction = Enum.FillDirection.Horizontal, + frameProps = { + Size = UDim2.new(1, 0, 0, BAR_HEIGHT), + }, + }, + -- , + React.createElement(Text, { + text = _G.DEVTOOLS_VERSION or "", + }), + --
+ React.createElement(TabBar, { + currentTab = tab, + id = "DevTools", + selectTab = setTab, + tabs = tabs, + type = "navigation", + }) + )), + React.createElement( + Div, + { + name = "components-tab", + frameProps = { + Position = UDim2.fromOffset(0, BAR_HEIGHT), + Size = UDim2.new(1, 0, 1, -BAR_HEIGHT), + Visible = tab == "components", + }, + }, + -- todo: render Components instead of text + React.createElement(Text, { + text = "Components tab!", + }) + -- React.createElement(Components, { + -- portalContainer = componentsPortalContainer, + -- }) + ), + React.createElement( + Div, + { + name = "profiler-tabs", + frameProps = { + Position = UDim2.fromOffset(0, BAR_HEIGHT), + Size = UDim2.new(1, 0, 1, -BAR_HEIGHT), + Visible = tab == "profiler", + }, + }, + React.createElement(Profiler, { + portalContainer = profilerPortalContainer, + }) + ), + React.createElement(SettingsModal) + ) + ) + ) + -- ) + -- warnIfLegacyBackendDetected and React.createElement(WarnIfLegacyBackendDetected), + -- warnIfUnsupportedVersionDetected and React.createElement(UnsupportedVersionDialog) + -- ) + ) + ) + ) + ) +end +exports.default = DevTools +exports.DevTools = DevTools + +return exports diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/ChartNode.luau b/packages/react-devtools-shared/src/devtools/views/Profiler/ChartNode.luau new file mode 100644 index 00000000..0d20d688 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/ChartNode.luau @@ -0,0 +1,113 @@ +--!strict +-- upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/devtools/views/Profiler/ChartNode.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 exports = {} + +local React = require(Packages.React) +local ReactRoblox = require(Packages.ReactRoblox) + +-- import styles from './ChartNode.css'; + +type SyntheticMouseEvent = any + +type Props = { + -- color: string, + color: Color3, + height: number, + isDimmed: boolean?, + label: string, + onClick: (event: SyntheticMouseEvent) -> (), + onDoubleClick: ((event: SyntheticMouseEvent) -> ())?, + onMouseEnter: (event: SyntheticMouseEvent) -> (), + onMouseLeave: (event: SyntheticMouseEvent) -> (), + placeLabelAboveNode: boolean?, + -- deviation: pass textColor directly instead + textColor: Color3, + width: number, + x: number, + -- y: number, +} + +local minWidthToDisplay = 35 + +local function ChartNode(props: Props) + local color = props.color + local height = props.height + local isDimmed = if props.isDimmed == nil then false else props.isDimmed + local label = props.label + local onClick = props.onClick + local onMouseEnter = props.onMouseEnter + local onMouseLeave = props.onMouseLeave + local onDoubleClick = props.onDoubleClick + -- deviation: pass textColor directly instead + local textColor = props.textColor + local width = props.width + local x = props.x + -- local y = props.y + + if onDoubleClick ~= nil then + warn("todo: double click not supported") + end + + return React.createElement("TextButton", { + Name = label, + BackgroundColor3 = color, + TextColor3 = textColor, + BackgroundTransparency = if isDimmed then 0.6 else 0.2, + -- todo: display text only if width is above a threshold + Text = label, + Size = UDim2.new(width, 0, 0, height), + Position = UDim2.new(x, 0, 0, 0), + TextTruncate = Enum.TextTruncate.AtEnd, + [ReactRoblox.Event.MouseEnter] = onMouseEnter, + [ReactRoblox.Event.MouseLeave] = onMouseLeave, + [ReactRoblox.Event.MouseButton1Click] = onClick, + }) + + -- return ( + -- + -- + -- {width >= minWidthToDisplay && ( + -- + --
+ -- {label} + --
+ --
+ -- )} + --
+ -- ); +end +exports.ChartNode = ChartNode +exports.default = ChartNode + +return exports diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/CommitFlamegraph.luau b/packages/react-devtools-shared/src/devtools/views/Profiler/CommitFlamegraph.luau new file mode 100644 index 00000000..116f7006 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/CommitFlamegraph.luau @@ -0,0 +1,273 @@ +--!strict +-- upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/devtools/views/Profiler/CommitFlamegraph.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) +local Array = LuauPolyfill.Array +local Map = LuauPolyfill.Map +local console = LuauPolyfill.console + +type Map = LuauPolyfill.Map +type Array = LuauPolyfill.Array + +local Div = require(script.Parent.Parent.roblox.Div) +local Text = require(script.Parent.Parent.roblox.Text) + +local exports = {} + +local React = require(Packages.React) +local forwardRef = React.forwardRef +local useCallback = React.useCallback +local useContext = React.useContext +local useMemo = React.useMemo +local useState = React.useState + +-- import AutoSizer from 'react-virtualized-auto-sizer'; +-- import {FixedSizeList} from 'react-window'; +local ProfilerContextModule = require(script.Parent.ProfilerContext) +local ProfilerContext = ProfilerContextModule.ProfilerContext +local NoCommitData = require(script.Parent.NoCommitData).default +local CommitFlamegraphListItemModule = require(script.Parent.CommitFlamegraphListItem) +local CommitFlamegraphListItem = CommitFlamegraphListItemModule.default +-- local HoveredFiberInfo = require(script.Parent.HoveredFiberInfo).default +local scale = require(script.Parent.utils).scale +-- import {useHighlightNativeElement} from '../hooks'; +local StoreContext = require(script.Parent.Parent.context).StoreContext +-- import {SettingsContext} from '../Settings/SettingsContext'; +-- local Tooltip = require(script.Parent.Tooltip) + +-- local styles = require(script.Parent.CommitFlamegraph.css) + +-- import type {TooltipFiberData} from './HoveredFiberInfo'; +type TooltipFiberData = any +local FlamegraphChartBuilder = require(script.Parent.FlamegraphChartBuilder) +type ChartData = FlamegraphChartBuilder.ChartData +type ChartNode = FlamegraphChartBuilder.ChartNode +local types = require(script.Parent.types) +type CommitTree = types.CommitTree + +-- deviation: avoid cyclic import and require move ItemData type from CommitFlamegraphListItem +export type ItemData = CommitFlamegraphListItemModule.ItemData + +-- deviation: pre-declaration +local CommitFlamegraph + +local function CommitFlamegraphAutoSizer(_: {}): React.ReactElement + local profilerStore = useContext(StoreContext):getProfilerStore() + local profilerValue = useContext(ProfilerContext) + local rootID = profilerValue.rootID + local selectedCommitIndex = profilerValue.selectedCommitIndex + local selectFiber = profilerValue.selectFiber + + local profilingCache = profilerStore:profilingCache() + + local deselectCurrentFiber = useCallback(function(event) + -- event.stopPropagation(); + selectFiber(nil, nil) + end, { selectFiber }) + + -- print("selectedCommitIndex", selectedCommitIndex) + -- print("profilingCache", profilingCache) + + local commitTree: CommitTree | nil = nil + local chartData: ChartData | nil = nil + if selectedCommitIndex ~= nil then + commitTree = profilingCache:getCommitTree({ + commitIndex = selectedCommitIndex, + rootID = (rootID :: any) :: number, + }) + + -- print("commitTree", commitTree) + + chartData = profilingCache:getFlamegraphChartData({ + commitIndex = selectedCommitIndex, + commitTree = commitTree, + rootID = (rootID :: any) :: number, + }) + -- print("chartData", chartData) + end + + if commitTree ~= nil and chartData ~= nil and chartData.depth > 0 then + return React.createElement( + Div, + { + name = "flamegraph-container", + -- className={styles.Container} + onClick = deselectCurrentFiber, + }, + React.createElement(CommitFlamegraph, { + chartData = (chartData :: any) :: ChartData, + commitTree = (commitTree :: any) :: CommitTree, + }) + -- React.createElement(AutoSizer, {}, function(info) + -- local height = info.height + -- local width = info.width + -- -- Force Flow types to avoid checking for `null` here because there's no static proof that + -- -- by the time this render prop function is called, the values of the `let` variables have not changed. + -- return React.createElement(CommitFlamegraph, { + -- chartData = (chartData :: any) :: ChartData, + -- commitTree = (commitTree :: any) :: CommitTree, + -- height = height, + -- width = width, + -- }) + -- end) + ) + else + return React.createElement(NoCommitData) + end +end +exports.CommitFlamegraphAutoSizer = CommitFlamegraphAutoSizer +exports.default = CommitFlamegraphAutoSizer + +type Props = { + chartData: ChartData, + commitTree: CommitTree, + -- height: number, + -- width: number, +} + +function CommitFlamegraph(props: Props) + local chartData = props.chartData + -- local commitTree = props.commitTree + -- local height = props.height + -- local width = props.width + local hoveredFiberData, setHoveredFiberData = useState(nil :: TooltipFiberData | nil) + -- local {lineHeight} = useContext(SettingsContext); + -- todo: implement SettingsContext + local lineHeight = 10 + local profilerValue = useContext(ProfilerContext) + + local selectFiber = profilerValue.selectFiber + local selectedFiberID = profilerValue.selectedFiberID + -- local highlighter = useHighlightNativeElement() + -- local highlightNativeElement = highlighter.highlightNativeElement + -- local clearHighlightNativeElement = highlighter.clearHighlightNativeElement + + local selectedChartNodeIndex = useMemo(function() + if selectedFiberID == nil then + -- deviation: use 1 to select first row + return 1 + end + -- The selected node might not be in the tree for this commit, + -- so it's important that we have a fallback plan. + local depth = chartData.idToDepthMap:get(selectedFiberID) + -- deviation: depth 1 is row 1 and first depth is 1 + return if depth ~= nil then depth else 1 + end, { chartData :: any, selectedFiberID }) + + local selectedChartNode = useMemo(function() + if selectedFiberID ~= nil then + return Array.find(chartData.rows[selectedChartNodeIndex], function(chartNode) + return chartNode.id == selectedFiberID + end) or nil + end + return nil + end, { chartData :: any, selectedFiberID, selectedChartNodeIndex }) + + local handleElementMouseEnter = useCallback(function(args) + local id = args.id + local name = args.name + + -- highlightNativeElement(id) -- Highlight last hovered element. + setHoveredFiberData({ id, name }) -- Set hovered fiber data for tooltip + end, { --[[ highlightNativeElement ]] + }) + + local handleElementMouseLeave = useCallback(function() + -- clearHighlightNativeElement() -- clear highlighting of element on mouse leave + setHoveredFiberData(nil :: any) -- clear hovered fiber data for tooltip + end, { --[[clearHighlightNativeElement]] + }) + + local itemData: ItemData = useMemo(function() + return { + chartData = chartData, + onElementMouseEnter = handleElementMouseEnter, + onElementMouseLeave = handleElementMouseLeave, + scaleX = scale( + 0, + if selectedChartNode ~= nil then selectedChartNode.treeBaseDuration else chartData.baseDuration, + 0, + 1 -- width + ), + selectedChartNode = selectedChartNode, + selectedChartNodeIndex = selectedChartNodeIndex, + selectFiber = selectFiber, + -- width = width, + } + end, { + chartData :: any, + handleElementMouseEnter, + handleElementMouseLeave, + selectedChartNode, + selectedChartNodeIndex, + selectFiber, + -- width, + }) + + -- Tooltip used to show summary of fiber info on hover + local tooltipLabel = useMemo(function() + return if hoveredFiberData ~= nil + -- todo: implement tooltip + then nil -- React.createElement(HoveredFiberInfo, { fiberData = hoveredFiberData }) + else nil + end, { hoveredFiberData }) + + local items = {} + for i = 1, chartData.depth do + items[i] = React.createElement(CommitFlamegraphListItem, { + key = tostring(i), + data = itemData, + index = i, + }) + end + + return React.createElement(Div, { + name = "commit-flamegraph", + direction = Enum.FillDirection.Vertical, + layoutProps = { + VerticalAlignment = Enum.VerticalAlignment.Top, + }, + }, items) + -- return React.createElement( + -- Tooltip, + -- { + -- label = tooltipLabel, + -- }, + -- React.createElement(FixedSizeList, { + -- height = height, + -- innerElementType = InnerElementType, + -- itemCount = chartData.depth, + -- itemData = itemData, + -- itemSize = lineHeight, + -- width = width, + -- }, CommitFlamegraphListItem) + -- ) +end + +-- local InnerElementType = forwardRef(({children, ...rest}, ref) => ( +-- +-- +-- +-- +-- +-- +-- {children} +-- +-- )); + +return exports diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/CommitFlamegraphListItem.luau b/packages/react-devtools-shared/src/devtools/views/Profiler/CommitFlamegraphListItem.luau new file mode 100644 index 00000000..6c6e31bb --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/CommitFlamegraphListItem.luau @@ -0,0 +1,179 @@ +--!strict +-- upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/devtools/views/Profiler/CommitFlamegraphListItem.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) +local Array = LuauPolyfill.Array +local Map = LuauPolyfill.Map +local console = LuauPolyfill.console + +type Map = LuauPolyfill.Map +type Array = LuauPolyfill.Array + +local Div = require(script.Parent.Parent.roblox.Div) +local Text = require(script.Parent.Parent.roblox.Text) + +local exports = {} + +local React = require(Packages.React) +local Fragment = React.Fragment +local memo = React.memo +local useCallback = React.useCallback +local useContext = React.useContext + +-- import {areEqual} from 'react-window'; +local barWidthThreshold = require(script.Parent.constants).barWidthThreshold +local getGradientColor = require(script.Parent.utils).getGradientColor +local ChartNode = require(script.Parent.ChartNode).default +-- import {SettingsContext} from '../Settings/SettingsContext'; + +local FlamegraphChartBuilder = require(script.Parent.FlamegraphChartBuilder) +type ChartNodeType = FlamegraphChartBuilder.ChartNode +type ChartData = FlamegraphChartBuilder.ChartData + +-- deviation: define ItemData type here to avoid cyclic requires +-- import type {TooltipFiberData} from './HoveredFiberInfo'; +type TooltipFiberData = any +export type ItemData = { + chartData: ChartData, + onElementMouseEnter: (fiberData: TooltipFiberData) -> (), + onElementMouseLeave: () -> (), + scaleX: (value: number, fallbackValue: number) -> number, + selectedChartNode: ChartNodeType | nil, + selectedChartNodeIndex: number, + selectFiber: (id: number | nil, name: string | nil) -> (), + -- width: number, +} + +type Props = { + data: ItemData, + index: number, + -- style: Object, +} + +local function CommitFlamegraphListItem(props: Props) + local data = props.data + local index = props.index + -- local style = props.style + + local chartData = data.chartData + local onElementMouseEnter = data.onElementMouseEnter + local onElementMouseLeave = data.onElementMouseLeave + local scaleX = data.scaleX + local selectedChartNode = data.selectedChartNode + local selectedChartNodeIndex = data.selectedChartNodeIndex + local selectFiber = data.selectFiber + -- deviation: use Roblox scaling unit + local width = 1 -- data.width + + local renderPathNodes = chartData.renderPathNodes + local maxSelfDuration = chartData.maxSelfDuration + local rows = chartData.rows + + -- todo: implement SettingsContext + local lineHeight = 14 -- useContext(SettingsContext).lineHeight; + + local handleClick = useCallback(function(event, id: number, name: string) + -- event.stopPropagation() + selectFiber(id, name) + end, { selectFiber }) + + local function handleMouseEnter(nodeData: ChartNodeType) + local id = nodeData.id + local name = nodeData.name + onElementMouseEnter({ id = id, name = name }) + end + + local function handleMouseLeave() + onElementMouseLeave() + end + + -- List items are absolutely positioned using the CSS "top" attribute. + -- The "left" value will always be 0. + -- Since height is fixed, and width is based on the node's duration, + -- We can ignore those values as well. + -- local top = parseInt(style.top, 10) + + local row = rows[index] + + local selectedNodeOffset = scaleX(if selectedChartNode ~= nil then selectedChartNode.offset else 0, width) + + return React.createElement( + Div, + { + name = "flamegraph-list-item", + direction = false, -- Enum.FillDirection.Horizontal, + maxHeight = lineHeight, + frameProps = { + ClipsDescendants = true, + }, + }, + Array.map(row, function(chartNode: ChartNodeType, nodeIndex: number): any + local didRender = chartNode.didRender + local id = chartNode.id + local label = chartNode.label + local name = chartNode.name + local offset = chartNode.offset + local selfDuration = chartNode.selfDuration + local treeBaseDuration = chartNode.treeBaseDuration + + local nodeOffset = scaleX(offset, width) + local nodeWidth = scaleX(treeBaseDuration, width) + + -- Filter out nodes that are too small to see or click. + -- This also helps render large trees faster. + -- if nodeWidth < barWidthThreshold then + -- return nil + -- end + + -- Filter out nodes that are outside of the horizontal window. + -- if nodeOffset + nodeWidth < selectedNodeOffset or nodeOffset > selectedNodeOffset + width then + -- return nil + -- end + + local color = Color3.fromRGB(182, 182, 182) -- "url(#didNotRenderPattern)" + local textColor = Color3.fromHex("#333333") --"var(--color-commit-did-not-render-pattern-text)" + if didRender then + color = getGradientColor(selfDuration / maxSelfDuration) + textColor = Color3.fromHex("#000000") -- "var(--color-commit-gradient-text)" + elseif renderPathNodes:has(id) then + color = Color3.fromHex("#cfd1d5") -- "var(--color-commit-did-not-render-fill)" + textColor = Color3.fromHex("#000000") -- "var(--color-commit-did-not-render-fill-text)" + end + + return React.createElement(ChartNode, { + color = color, + height = lineHeight, + isDimmed = index < selectedChartNodeIndex, + key = id, + label = label, + onClick = function(event) + handleClick(event, id, name) + end, + onMouseEnter = function() + handleMouseEnter(chartNode) + end, + onMouseLeave = handleMouseLeave, + textColor = textColor, + -- textStyle={color: textColor}, + width = nodeWidth, + x = nodeOffset - selectedNodeOffset, + -- y = top, + }) + end) + ) +end + +-- todo: implement areEqual to use memo +-- exports.default = memo(CommitFlamegraphListItem, areEqual) +exports.default = CommitFlamegraphListItem +exports.CommitFlamegraphListItem = exports.default + +return exports diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.luau b/packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.luau index c587645d..67e056be 100644 --- a/packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.luau +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.luau @@ -1,4 +1,5 @@ --!strict +-- upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js --[[* * Copyright (c) Facebook, Inc. and its affiliates. * @@ -17,14 +18,7 @@ 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 @@ -89,8 +83,10 @@ local function updateTree(commitTree: CommitTree, operations: Array): Co -- Clone the original tree so edits don't affect it. local nodes = Map.new(commitTree.nodes) + -- print("update trees with nodes", nodes._map) -- Clone nodes before mutating them so edits don't affect the original. local function getClonedNode(id: number): CommitTreeNode + -- print("> getClonedNode", id) local clonedNode = table.clone((nodes:get(id) :: any) :: CommitTreeNode) nodes:set(id, clonedNode) return clonedNode @@ -237,6 +233,7 @@ local function recursivelyInitializeTree( nodes: Map, dataForRoot: ProfilingDataForRootFrontend ): () + -- print("dataForRoot.snapshots", dataForRoot.snapshots) local node = dataForRoot.snapshots:get(id) if node ~= nil then nodes:set(id, { @@ -262,35 +259,54 @@ local function getCommitTree(ref: { rootID: number, }): CommitTree local commitIndex, profilerStore, rootID = ref.commitIndex, ref.profilerStore, ref.rootID + -- print("commitIndex", commitIndex) + -- print("profilerStore", profilerStore) + if not rootToCommitTreeMap:has(rootID) then rootToCommitTreeMap:set(rootID, {}) end local commitTrees = (rootToCommitTreeMap:get(rootID) :: any) :: Array + + -- print("commitTrees", commitTrees) if commitIndex <= #commitTrees then return commitTrees[commitIndex] end + local profilingData = profilerStore:profilingData() -- 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) -- 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. + + 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. + local nodes = Map.new() + + -- Construct the initial tree. recursivelyInitializeTree(rootID, 0, nodes, dataForRoot :: ProfilingDataForRootFrontend) -- Mutate the tree + + -- Mutate the tree if operations ~= nil and commitIndex <= #operations then + -- print("try updateTree...") local commitTree = updateTree({ nodes = nodes, rootID = rootID }, operations[commitIndex]) + -- print("update tree done!") + if __DEBUG__ then __printTree(commitTree) end + table.insert(commitTrees, commitTree) + return commitTree end else @@ -299,15 +315,20 @@ local function getCommitTree(ref: { 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', diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/FlamegraphChartBuilder.luau b/packages/react-devtools-shared/src/devtools/views/Profiler/FlamegraphChartBuilder.luau index b3f66780..264e2cd1 100644 --- a/packages/react-devtools-shared/src/devtools/views/Profiler/FlamegraphChartBuilder.luau +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/FlamegraphChartBuilder.luau @@ -124,16 +124,14 @@ local function getChartData(ref: { if currentDepth > #rows then table.insert(rows, { chartNode }) else - table.insert(rows[currentDepth - 1], chartNode) + -- deviation: remove -1 because of 1-indexing + table.insert(rows[currentDepth], 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 + + for i = #children, 1, -1 do + local childID = children[i] + local childChartNode = walkTree(childID, rightOffset, currentDepth + 1) + rightOffset -= childChartNode.treeBaseDuration end return chartNode end @@ -158,7 +156,7 @@ local function getChartData(ref: { -- FIXME Luau: Luau doesn't understand error() narrows, needs type states baseDuration += (node :: CommitTreeNode).treeBaseDuration -- 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) + walkTree(id, baseDuration, 1) -- deviation END i -= 1 end diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/NoCommitData.luau b/packages/react-devtools-shared/src/devtools/views/Profiler/NoCommitData.luau new file mode 100644 index 00000000..31f56326 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/NoCommitData.luau @@ -0,0 +1,50 @@ +--!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 Div = require(script.Parent.Parent.roblox.Div) +local Text = require(script.Parent.Parent.roblox.Text) + +local exports = {} + +local React = require(Packages.React) +local forwardRef = React.forwardRef +local useCallback = React.useCallback +local useContext = React.useContext +local useMemo = React.useMemo +local useState = React.useState + +-- import styles from './NoCommitData.css'; + +local function NoCommitData(_: {}) + return React.createElement( + Div, + {}, + React.createElement(Text, { + text = "There is no data matching the current filter criteria.", + expand = false, + }), + React.createElement(Text, { + text = "Try adjusting the commit filter in Profiler settings.", + expand = false, + }) + ) +end +exports.NoCommitData = NoCommitData +exports.default = NoCommitData + +return exports diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/Profiler.lua b/packages/react-devtools-shared/src/devtools/views/Profiler/Profiler.lua new file mode 100644 index 00000000..1493f82f --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/Profiler.lua @@ -0,0 +1,366 @@ +--!strict +-- upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/devtools/views/Profiler/Profiler.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) +local Array = LuauPolyfill.Array + +type Map = LuauPolyfill.Map +type Array = LuauPolyfill.Array + +local exports = {} + +local Div = require(script.Parent.Parent.roblox.Div) +local Text = require(script.Parent.Parent.roblox.Text) + +local React = require(Packages.React) +local Fragment = React.Fragment +local useContext = React.useContext +-- local ModalDialog = require(script.Parent.Parent.ModalDialog).ModalDialog +local ProfilerContextModule = require(script.Parent.ProfilerContext) +local ProfilerContext = ProfilerContextModule.ProfilerContext +local TabBarModule = require(script.Parent.Parent.TabBar) +local TabBar = TabBarModule.TabBar +type TabInfo = TabBarModule.TabInfo +-- local ClearProfilingDataButton = require(script.Parent.ClearProfilingDataButton).default +local CommitFlamegraph = require(script.Parent.CommitFlamegraph).default +-- local CommitRanked = require(script.Parent.CommitRanked).default +-- local Interactions = require(script.Parent.Interactions).default +-- local RootSelector = require(script.Parent.RootSelector).default +local RecordToggle = require(script.Parent.RecordToggle).default +-- local ReloadAndProfileButton = require(script.Parent.ReloadAndProfileButton).default +-- local ProfilingImportExportButtons = require(script.Parent.ProfilingImportExportButtons).default +local SnapshotSelector = require(script.Parent.SnapshotSelector).default +local SidebarCommitInfo = require(script.Parent.SidebarCommitInfo).default +-- local SidebarInteractions = require(script.Parent.SidebarInteractions).default +local SidebarSelectedFiberInfo = require(script.Parent.SidebarSelectedFiberInfo).default + +-- local SettingsModal = require(script.Parent.Parent.Settings.SettingsModal).default +local SettingsModalContextToggle = require(script.Parent.Parent.Settings.SettingsModalContextToggle).default +-- local SettingsModalContextModule = require(script.Parent.Parent.Settings.SettingsModalContext) +-- local SettingsModalContextController = SettingsModalContextModule.SettingsModalContextController +local portaledContent = require(script.Parent.Parent.portaledContent).default +local Store = require(script.Parent.Parent.Parent.store) +type Store = Store.Store + +-- local styles = require(script.Parent.Profiler) + +-- deviation: pre-declaration +local RecordingInProgress +local ProcessingData +local NoProfilingData +local ProfilingNotSupported +local tabs: Array + +local BAR_HEIGHT = 40 + +local function TodoView(props: { name: string }) + return React.createElement( + Div, + {}, + React.createElement(Text, { + text = string.format("Todo: implement view `%s`", props.name), + }) + ) +end + +local function Profiler(_: {}) + local profilerStore = useContext(ProfilerContext) + local didRecordCommits = profilerStore.didRecordCommits + local isProcessingData = profilerStore.isProcessingData + local isProfiling = profilerStore.isProfiling + local selectedCommitIndex = profilerStore.selectedCommitIndex + local selectedFiberID = profilerStore.selectedFiberID + local selectedTabID = profilerStore.selectedTabID + local selectTab = profilerStore.selectTab + local supportsProfiling = profilerStore.supportsProfiling + + local view: React.ReactElement = nil + if didRecordCommits then + if selectedTabID == "flame-chart" then + view = React.createElement(CommitFlamegraph) + elseif selectedTabID == "ranked-chart" then + view = React.createElement(TodoView, { name = "CommitRanked" }) + -- view = React.createElement(CommitRanked) + elseif selectedTabID == "interactions" then + view = React.createElement(TodoView, { name = "interactions" }) + -- view = React.createElement(Interactions) + end + elseif isProfiling then + view = React.createElement(RecordingInProgress) + elseif isProcessingData then + view = React.createElement(TodoView, { name = "ProcessingData" }) + -- view = React.createElement(ProcessingData) + elseif supportsProfiling then + view = React.createElement(NoProfilingData) + else + view = React.createElement(ProfilingNotSupported) + end + + local sidebar = nil + if not isProfiling and not isProcessingData and didRecordCommits then + if selectedTabID == "interactions" then + -- sidebar = React.createElement(SidebarInteractions) + -- todo + view = React.createElement(TodoView, { name = "SidebarInteractions" }) + elseif selectedTabID == "flame-chart" or selectedTabID == "ranked-chart" then + -- TRICKY + -- Handle edge case where no commit is selected because of a min-duration filter update. + -- In that case, the selected commit index would be null. + -- We could still show a sidebar for the previously selected fiber, + -- but it would be an odd user experience. + -- TODO (ProfilerContext) This check should not be necessary. + if selectedCommitIndex ~= nil then + if selectedFiberID ~= nil then + sidebar = React.createElement(SidebarSelectedFiberInfo) + else + sidebar = React.createElement(SidebarCommitInfo) + end + end + end + end + + local leftFraction = 0.7 + + -- deviation: use Roblox view objects + -- return React.createElement( + -- SettingsModalContextController, + -- nil, + return React.createElement( + "Frame", + { + Name = "container", + BackgroundTransparency = 1, + Size = UDim2.fromScale(1, 1), + }, + React.createElement( + Div, + { + name = "columns", + -- className=styles.Profiler + direction = Enum.FillDirection.Horizontal, + }, + React.createElement( + Div, + { + name = "left-column", + -- className=styles.LeftColumn + frameProps = { + Size = UDim2.fromScale(leftFraction, 1), + }, + }, + React.createElement( + "Frame", + { + Name = "toolbar-container", + -- className=styles.Toolbar + BackgroundTransparency = 1, + Size = UDim2.new(1, 0, 0, BAR_HEIGHT), + }, + React.createElement("UITableLayout", { + Name = "table-layout", + FillEmptySpaceColumns = true, + FillDirection = Enum.FillDirection.Vertical, + SortOrder = Enum.SortOrder.LayoutOrder, + HorizontalAlignment = Enum.HorizontalAlignment.Left, + VerticalAlignment = Enum.VerticalAlignment.Center, + }), + React.createElement( + "Frame", + { + Name = "row", + BackgroundTransparency = 1, + Size = UDim2.new(1, 0, 0, BAR_HEIGHT), + }, + -- React.createElement(RecordToggle, { + -- disabled = not supportsProfiling, + -- }), + -- React.createElement(ReloadAndProfileButton), + -- React.createElement(ClearProfilingDataButton), + -- React.createElement(ProfilingImportExportButtons), + -- React.createElement(Div, { + -- -- className = styles.VRule + -- }), + React.createElement(TabBar, { + currentTab = selectedTabID, + id = "Profiler", + selectTab = selectTab, + tabs = tabs, + type = "profiler", + }), + -- React.createElement(RootSelector) + -- React.createElement(Div, { + -- -- className = styles.Space + -- }) + React.createElement(SettingsModalContextToggle), + -- didRecordCommits + -- and React.createElement( + -- Fragment, + -- nil, + -- React.createElement(Div, { + -- -- className = styles.VRule + -- }), + React.createElement(SnapshotSelector) + -- ) + ) + ), + React.createElement( + Div, + { + name = "content", + -- className = styles.Content, + frameProps = { + Size = UDim2.new(1, 0, 1, -BAR_HEIGHT), + }, + }, + view + -- React.createElement(ModalDialog) + ) + ), + React.createElement(Div, { + name = "right-column", + -- className=styles.RightColumn + frameProps = { + Position = UDim2.fromScale(leftFraction, 0), + Size = UDim2.fromScale(1 - leftFraction, 1), + LayoutOrder = 2, + }, + }, sidebar) + ) + -- deviation: move SettingsModal up + -- React.createElement(SettingsModal) + ) + -- ) +end + +tabs = { + { + id = "flame-chart", + icon = "flame-chart", + label = "Flamegraph", + title = "Flamegraph chart", + }, + -- { + -- id = "ranked-chart", + -- icon = "ranked-chart", + -- label = "Ranked", + -- title = "Ranked chart", + -- }, + -- { + -- id = "interactions", + -- icon = "interactions", + -- label = "Interactions", + -- title = "Profiled interactions", + -- }, +} + +function NoProfilingData() + -- deviation: use Roblox view objects + return React.createElement( + Div, + { + xAlignment = Enum.HorizontalAlignment.Center, + }, + React.createElement(Text, { + text = "No profiling data has been recorded.", + expand = false, + }), + React.createElement( + Div, + { + -- direction = Enum.FillDirection.Vertical, + }, + React.createElement(Text, { + text = "Click the record button", + expand = false, + }), + -- todo record button + React.createElement(RecordToggle), + React.createElement(Text, { + text = "to start recording.", + expand = false, + }) + ) + ) +end + +function ProfilingNotSupported() + return React.createElement( + Div, + {}, + React.createElement(Text, { + text = "Profiling not supported.", + frameProps = { Size = UDim2.fromOffset(0, 50) }, + }), + React.createElement(Text, { + text = "Profiling support requires either a development or production-profiling build of React v16.5+.", + expand = false, + frameProps = { Size = UDim2.fromOffset(0, 0) }, + }) + ) + --

+ -- Learn more at{' '} + -- + -- reactjs.org/link/profiling + -- + -- . + --

+end + +-- function ProcessingData () +--
+--
Processing data...
+--
This should only take a minute.
+--
+-- end + +function RecordingInProgress() + -- deviation: use Roblox view objects + return React.createElement( + Div, + { + xAlignment = Enum.HorizontalAlignment.Center, + }, + React.createElement(Text, { + text = "Profiling is in progress...", + expand = false, + }), + React.createElement( + Div, + { + -- direction = Enum.FillDirection.Vertical, + }, + React.createElement(Text, { + text = "Click the record button", + expand = false, + }), + -- todo record button + React.createElement(RecordToggle), + React.createElement(Text, { + text = "to stop recording.", + expand = false, + }) + ) + ) +end + +local function onErrorRetry(store: Store) + -- If an error happened in the Profiler, + -- we should clear data on retry (or it will just happen again). + store:getProfilerStore().profilingData = nil +end + +exports.default = portaledContent(Profiler, onErrorRetry) + +return exports diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/ProfilerContext.luau b/packages/react-devtools-shared/src/devtools/views/Profiler/ProfilerContext.luau new file mode 100644 index 00000000..035637d4 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/ProfilerContext.luau @@ -0,0 +1,312 @@ +--!strict +-- upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/devtools/views/Profiler/ProfilerContext.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 exports = {} + +local React = require(Packages.React) +local createContext = React.createContext +local useCallback = React.useCallback +local useContext = React.useContext +local useMemo = React.useMemo +local useState = React.useState + +local ReactRoblox = require(Packages.ReactRoblox) +local batchedUpdates = ReactRoblox.unstable_batchedUpdates + +local hooksModule = require(script.Parent.Parent.hooks) +local useLocalStorage = hooksModule.useLocalStorage +local useSubscription = hooksModule.useSubscription + +local TreeContextModule = require(script.Parent.Parent.Components.TreeContext) +local TreeDispatcherContext = TreeContextModule.TreeDispatcherContext +local TreeStateContext = TreeContextModule.TreeStateContext + +local contextModule = require(script.Parent.Parent.context) +local StoreContext = contextModule.StoreContext + +local types = require(script.Parent.types) +type ProfilingDataFrontend = types.ProfilingDataFrontend + +export type TabID = "flame-chart" | "ranked-chart" | "interactions" + +export type Context = { + -- Which tab is selected in the Profiler UI? + selectedTabID: TabID, + selectTab: (id: TabID) -> (), + + -- Store subscription based values. + -- The isProfiling value may be modified by the record button in the Profiler toolbar, + -- or from the backend itself (after a reload-and-profile action). + -- It is synced between the backend and frontend via a Store subscription. + didRecordCommits: boolean, + isProcessingData: boolean, + isProfiling: boolean, + profilingData: ProfilingDataFrontend | nil, + startProfiling: () -> (), + stopProfiling: () -> (), + supportsProfiling: boolean, + + -- Which root should profiling data be shown for? + -- This value should be initialized to either: + -- 1. The selected root in the Components tree (if it has any profiling data) or + -- 2. The first root in the list with profiling data. + rootID: number | nil, + setRootID: (id: number) -> (), + + -- Controls whether commits are filtered by duration. + -- This value is controlled by a filter toggle UI in the Profiler toolbar. + -- It impacts the commit selector UI as well as the fiber commits bar chart. + isCommitFilterEnabled: boolean, + setIsCommitFilterEnabled: (value: boolean) -> (), + minCommitDuration: number, + setMinCommitDuration: (value: number) -> (), + + -- Which commit is currently selected in the commit selector UI. + -- Note that this is the index of the commit in all commits (non-filtered) that were profiled. + -- This value is controlled by the commit selector UI in the Profiler toolbar. + -- It impacts the flame graph and ranked charts. + selectedCommitIndex: number | nil, + selectCommitIndex: (value: number | nil) -> (), + + -- Which fiber is currently selected in the Ranked or Flamegraph charts? + selectedFiberID: number | nil, + selectedFiberName: string | nil, + selectFiber: (id: number | nil, name: string | nil) -> (), + + -- Which interaction is currently selected in the Interactions graph? + selectedInteractionID: number | nil, + selectInteraction: (id: number | nil) -> (), +} + +local ProfilerContext = createContext((nil :: any) :: Context) +ProfilerContext.displayName = "ProfilerContext" + +type StoreProfilingState = { + didRecordCommits: boolean, + isProcessingData: boolean, + isProfiling: boolean, + profilingData: ProfilingDataFrontend | nil, + supportsProfiling: boolean, +} + +type Props = { + children: React.ReactNode, +} + +local function ProfilerContextController(props: Props) + local children = props.children + local store = useContext(StoreContext) + local selectedElementID = useContext(TreeStateContext).selectedElementID + local dispatch = useContext(TreeDispatcherContext) + + -- deviation: use getter method + -- local profilerStore = store.profilerStore + local profilerStore = store:getProfilerStore() + + local subscription = useMemo(function() + return { + getCurrentValue = function() + return { + -- deviation: use getter method + -- didRecordCommits = profilerStore.didRecordCommits, + didRecordCommits = profilerStore:didRecordCommits(), + -- isProcessingData = profilerStore.isProcessingData, + isProcessingData = profilerStore:isProcessingData(), + -- isProfiling = profilerStore.isProfiling, + isProfiling = profilerStore:isProfiling(), + -- profilingData = profilerStore.profilingData, + profilingData = profilerStore:profilingData(), + -- supportsProfiling = store.supportsProfiling, + supportsProfiling = store:getSupportsProfiling(), + } + end, + subscribe = function(callback: Function) + profilerStore:addListener("profilingData", callback) + profilerStore:addListener("isProcessingData", callback) + profilerStore:addListener("isProfiling", callback) + store:addListener("supportsProfiling", callback) + return function() + profilerStore:removeListener("profilingData", callback) + profilerStore:removeListener("isProcessingData", callback) + profilerStore:removeListener("isProfiling", callback) + store:removeListener("supportsProfiling", callback) + end + end, + } + end, { profilerStore :: any, store }) + local subscriptionState = useSubscription(subscription) -- + local didRecordCommits = subscriptionState.didRecordCommits + local isProcessingData = subscriptionState.isProcessingData + local isProfiling = subscriptionState.isProfiling + local profilingData = subscriptionState.profilingData + local supportsProfiling = subscriptionState.supportsProfiling + + local prevProfilingData, setPrevProfilingData = useState(nil :: ProfilingDataFrontend | nil) + local rootID, setRootID = useState(nil :: number | nil) + local selectedFiberID, selectFiberID = useState(nil :: number | nil) + local selectedFiberName, selectFiberName = useState(nil :: string | nil) + + local selectFiber = useCallback(function(id: number | nil, name: string | nil) + selectFiberID(id) + selectFiberName(name) + + -- Sync selection to the Components tab for convenience. + -- Keep in mind that profiling data may be from a previous session. + -- If data has been imported, we should skip the selection sync. + if id ~= nil and profilingData ~= nil and profilingData.imported == false then + -- We should still check to see if this element is still in the store. + -- It may have been removed during profiling. + if store:containsElement(id) then + dispatch({ + type = "SELECT_ELEMENT_BY_ID", + payload = id, + }) + end + end + end, { dispatch :: any, selectFiberID, selectFiberName, store, profilingData }) + + local setRootIDAndClearFiber = useCallback(function(id: number | nil) + selectFiber(nil, nil) + setRootID(id) + end, { setRootID :: any, selectFiber }) + + -- deviation: wrap batchedUpdates in a useEffect + React.useEffect(function() + if prevProfilingData ~= profilingData then + setPrevProfilingData(profilingData) + + local dataForRoots = if profilingData ~= nil then profilingData.dataForRoots else nil + if dataForRoots ~= nil then + -- deviation: Luau keys implementation gives an array instead of an iterator + local firstRootID = dataForRoots:keys()[1] or nil + + if rootID == nil or not dataForRoots:has(rootID) then + local selectedElementRootID = nil + if selectedElementID ~= nil then + selectedElementRootID = store:getRootIDForElement(selectedElementID) + end + if selectedElementRootID ~= nil and dataForRoots:has(selectedElementRootID) then + setRootIDAndClearFiber(selectedElementRootID) + else + setRootIDAndClearFiber(firstRootID) + end + end + end + end + end, { prevProfilingData, profilingData }) + + local startProfiling = useCallback(function() + return profilerStore:startProfiling() + end, { profilerStore }) + local stopProfiling = useCallback(function() + return profilerStore:stopProfiling() + end, { profilerStore }) + + local isCommitFilterEnabled, setIsCommitFilterEnabled = + useLocalStorage("React::DevTools::isCommitFilterEnabled", false) + + local minCommitDuration, setMinCommitDuration = useLocalStorage("minCommitDuration", 0) + + local selectedCommitIndex, selectCommitIndex = useState(nil :: number | nil) + local selectedTabID, selectTab = useState("flame-chart" :: TabID) + local selectedInteractionID, selectInteraction = useState(nil :: number | nil) + + -- deviation: wrap batchesUpdates in useEffect + React.useEffect(function() + if isProfiling then + batchedUpdates(function() + if selectedCommitIndex ~= nil then + selectCommitIndex(nil) + end + if selectedFiberID ~= nil then + selectFiberID(nil) + selectFiberName(nil) + end + if selectedInteractionID ~= nil then + selectInteraction(nil) + end + end) + end + end, { isProfiling :: any, selectedCommitIndex, selectedFiberID, selectedInteractionID }) + + local value = useMemo(function() + return { + selectedTabID = selectedTabID, + selectTab = selectTab, + + didRecordCommits = didRecordCommits, + isProcessingData = isProcessingData, + isProfiling = isProfiling, + profilingData = profilingData, + startProfiling = startProfiling, + stopProfiling = stopProfiling, + supportsProfiling = supportsProfiling, + + rootID = rootID, + setRootID = setRootIDAndClearFiber, + + isCommitFilterEnabled = isCommitFilterEnabled, + setIsCommitFilterEnabled = setIsCommitFilterEnabled, + minCommitDuration = minCommitDuration, + setMinCommitDuration = setMinCommitDuration, + + selectedCommitIndex = selectedCommitIndex, + selectCommitIndex = selectCommitIndex, + + selectedFiberID = selectedFiberID, + selectedFiberName = selectedFiberName, + selectFiber = selectFiber, + + selectedInteractionID = selectedInteractionID, + selectInteraction = selectInteraction, + } + end, { + selectedTabID :: any, + selectTab, + + didRecordCommits, + isProcessingData, + isProfiling, + profilingData, + startProfiling, + stopProfiling, + supportsProfiling, + + rootID, + setRootID, + setRootIDAndClearFiber, + + isCommitFilterEnabled, + setIsCommitFilterEnabled, + minCommitDuration, + setMinCommitDuration, + + selectedCommitIndex, + selectCommitIndex, + + selectedFiberID, + selectedFiberName, + selectFiber, + + selectedInteractionID, + selectInteraction, + }) + + return React.createElement(ProfilerContext.Provider, { + value = value, + }, children) +end + +exports.ProfilerContext = ProfilerContext +exports.ProfilerContextController = ProfilerContextController + +return exports diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/RecordToggle.luau b/packages/react-devtools-shared/src/devtools/views/Profiler/RecordToggle.luau new file mode 100644 index 00000000..6c375fee --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/RecordToggle.luau @@ -0,0 +1,62 @@ +--!strict +-- upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/devtools/views/Profiler/RecordToggle.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) +local Array = LuauPolyfill.Array +local Map = LuauPolyfill.Map + +type Map = LuauPolyfill.Map +type Array = LuauPolyfill.Array + +local exports = {} + +local Div = require(script.Parent.Parent.roblox.Div) +local Text = require(script.Parent.Parent.roblox.Text) + +local React = require(Packages.React) +local useContext = React.useContext + +-- local Button = require(script.Parent.Parent.Button); +-- local ButtonIcon = require(script.Parent.Parent.ButtonIcon); +local ProfilerContextModule = require(script.Parent.ProfilerContext) +local ProfilerContext = ProfilerContextModule.ProfilerContext + +-- local styles = './RecordToggle.css'; + +export type Props = { + disabled: boolean?, +} + +local function RecordToggle(props: Props) + local disabled = props.disabled + local profilerValue = useContext(ProfilerContext) + local isProfiling = profilerValue.isProfiling + local startProfiling = profilerValue.startProfiling + local stopProfiling = profilerValue.stopProfiling + + -- local className = styles.InactiveRecordToggle; + -- if (disabled) { + -- className = styles.DisabledRecordToggle; + -- } else if (isProfiling) { + -- className = styles.ActiveRecordToggle; + -- } + + return React.createElement(Text, { + text = if isProfiling then "Stop profiling" else "Start profiling", + expand = false, + onMouseDown = if props.disabled then nil else if isProfiling then stopProfiling else startProfiling, + }) +end + +exports.RecordToggle = RecordToggle +exports.default = RecordToggle + +return exports diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/SidebarCommitInfo.luau b/packages/react-devtools-shared/src/devtools/views/Profiler/SidebarCommitInfo.luau new file mode 100644 index 00000000..d29f8cc3 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/SidebarCommitInfo.luau @@ -0,0 +1,163 @@ +--!strict +-- upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/devtools/views/Profiler/SideBarCommitInfo.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) +local Array = LuauPolyfill.Array +local Map = LuauPolyfill.Map + +type Map = LuauPolyfill.Map +type Array = LuauPolyfill.Array + +local exports = {} + +local Div = require(script.Parent.Parent.roblox.Div) +local Text = require(script.Parent.Parent.roblox.Text) + +local React = require(Packages.React) +local Fragment = React.Fragment +local useContext = React.useContext +local ProfilerContextModule = require(script.Parent.ProfilerContext) +local ProfilerContext = ProfilerContextModule.ProfilerContext + +local utilsModule = require(script.Parent.utils) +local formatDuration = utilsModule.formatDuration +local formatTime = utilsModule.formatTime +local contextModule = require(script.Parent.Parent.context) +local StoreContext = contextModule.StoreContext + +-- local styles = require(script.Parent.SidebarCommitInfo.css) + +export type Props = {} + +local function SidebarCommitInfo(_: Props) + local profilerValue = useContext(ProfilerContext) + local selectedCommitIndex = profilerValue.selectedCommitIndex + local rootID = profilerValue.rootID + local selectInteraction = profilerValue.selectInteraction + local selectTab = profilerValue.selectTab + + local profilerStore = useContext(StoreContext):getProfilerStore() + + if rootID == nil or selectedCommitIndex == nil then + return React.createElement(Text, { text = "Nothing selected", expand = true }) + end + + local interactions = profilerStore:getDataForRoot(rootID).interactions + local commitData = profilerStore:getCommitData(rootID, selectedCommitIndex) + local duration = commitData.duration + local interactionIDs = commitData.interactionIDs + local priorityLevel = commitData.priorityLevel + local timestamp = commitData.timestamp + + -- local function viewInteraction(interactionID) + -- selectTab("interactions") + -- selectInteraction(interactionID) + -- end + + return React.createElement( + Fragment, + {}, + React.createElement(Text, { + text = "Commit information", + expand = false, + frameProps = { + LayoutOrder = 1, + }, + }), + React.createElement( + Div, + { + name = "content", + frameProps = { + LayoutOrder = 2, + }, + }, + priorityLevel ~= nil + and React.createElement(Text, { + text = "Priority: " .. tostring(priorityLevel), + expand = false, + frameProps = { + LayoutOrder = 1, + }, + }), + React.createElement(Text, { + text = "Committed at: " .. formatTime(timestamp), + expand = false, + frameProps = { + LayoutOrder = 2, + }, + }), + React.createElement(Text, { + text = "Render duration: " .. formatDuration(duration), + expand = false, + frameProps = { + LayoutOrder = 3, + }, + }), + React.createElement(Text, { + text = "Interaction: ", + expand = false, + frameProps = { + LayoutOrder = 4, + }, + }) + ) + ) + -- return ( + -- + --
Commit information
+ --
+ --
    + -- {priorityLevel !== nil && ( + --
  • + -- :{' '} + -- {priorityLevel} + --
  • + -- )} + --
  • + -- :{' '} + -- {formatTime(timestamp)}s + --
  • + --
  • + -- :{' '} + -- {formatDuration(duration)}ms + --
  • + --
  • + -- : + --
    + -- {interactionIDs.length == 0 ? ( + --
    None
    + -- ) : nil} + -- {interactionIDs.map(interactionID => { + -- local interaction = interactions.get(interactionID); + -- if (interaction == nil) { + -- throw Error(`Invalid interaction "${interactionID}"`); + -- } + -- return ( + -- + -- ); + -- })} + --
    + --
  • + --
+ --
+ --
+ -- ); +end +exports.default = SidebarCommitInfo +exports.SidebarCommitInfo = SidebarCommitInfo + +return exports diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/SidebarSelectedFiberInfo.luau b/packages/react-devtools-shared/src/devtools/views/Profiler/SidebarSelectedFiberInfo.luau new file mode 100644 index 00000000..568f5883 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/SidebarSelectedFiberInfo.luau @@ -0,0 +1,186 @@ +--!strict +-- upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/devtools/views/Profiler/SidebarSelectedFiberInfo.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) +local Array = LuauPolyfill.Array +local Map = LuauPolyfill.Map + +type Map = LuauPolyfill.Map +type Array = LuauPolyfill.Array + +local exports = {} + +local Div = require(script.Parent.Parent.roblox.Div) +local Text = require(script.Parent.Parent.roblox.Text) + +local React = require(Packages.React) +local Fragment = React.Fragment +local useContext = React.useContext +local useEffect = React.useEffect +local useRef = React.useRef +local WhatChanged = require(script.Parent.WhatChanged).default +local ProfilerContextModule = require(script.Parent.ProfilerContext) +local ProfilerContext = ProfilerContextModule.ProfilerContext +local utilsModule = require(script.Parent.utils) +local formatDuration = utilsModule.formatDuration +local formatTime = utilsModule.formatTime +local contextModule = require(script.Parent.Parent.context) +local StoreContext = contextModule.StoreContext +-- import Button from '../Button'; +-- import ButtonIcon from '../ButtonIcon'; + +-- local styles = require(script.Parent.SidebarSelectedFiberInfo.css) + +export type Props = {} + +local BAR_HEIGHT = 40 + +local function SidebarSelectedFiberInfo(_: Props) + local profilerStore = useContext(StoreContext):getProfilerStore() + local profilerValue = useContext(ProfilerContext) + local rootID = profilerValue.rootID + local selectCommitIndex = profilerValue.selectCommitIndex + local selectedCommitIndex = profilerValue.selectedCommitIndex + local selectedFiberID = profilerValue.selectedFiberID + local selectedFiberName = profilerValue.selectedFiberName + local selectFiber = profilerValue.selectFiber + local profilingCache = profilerStore:profilingCache() + local selectedListItemRef = useRef(nil :: HTMLElement | nil) + + local commitIndices = profilingCache:getFiberCommits({ + fiberID = (selectedFiberID :: any) :: number, + rootID = (rootID :: any) :: number, + }) + + -- local handleKeyDown = event => { + -- switch (event.key) { + -- case 'ArrowUp': + -- if (selectedCommitIndex ~= nil) { + -- local prevIndex = commitIndices.indexOf(selectedCommitIndex); + -- local nextIndex = + -- prevIndex > 0 ? prevIndex - 1 : commitIndices.length - 1; + -- selectCommitIndex(commitIndices[nextIndex]); + -- } + -- event.preventDefault(); + -- break; + -- case 'ArrowDown': + -- if (selectedCommitIndex ~= nil) { + -- local prevIndex = commitIndices.indexOf(selectedCommitIndex); + -- local nextIndex = + -- prevIndex < commitIndices.length - 1 ? prevIndex + 1 : 0; + -- selectCommitIndex(commitIndices[nextIndex]); + -- } + -- event.preventDefault(); + -- break; + -- default: + -- break; + -- } + -- }; + + useEffect(function() + -- local selectedElement = selectedListItemRef.current + -- if selectedElement ~= nil and type(selectedElement.scrollIntoView) == "function" then + -- selectedElement.scrollIntoView({block: 'nearest', inline: 'nearest'}); + -- end + end, { selectedCommitIndex }) + + local listItems = {} + + for i, commitIndex in commitIndices do + local commitData = profilerStore:getCommitData((rootID :: any) :: number, commitIndex) + local duration = commitData.duration + local timestamp = commitData.timestamp + + table.insert( + listItems, + React.createElement(Text, { + key = tostring(commitIndex), + ref = if selectedCommitIndex == commitIndex then selectedListItemRef else nil, + expand = false, + text = `{formatTime(timestamp)}s for {formatDuration(duration)}ms`, + onMouseDown = function() + selectCommitIndex(commitIndex) + end, + frameProps = { LayoutOrder = i + 5 }, + }) + -- , + ) + end + + return React.createElement( + Fragment, + {}, + React.createElement( + Div, + { + name = "toolbar", + direction = Enum.FillDirection.Horizontal, + -- className = styles.Toolbar, + frameProps = { + Size = UDim2.new(1, 0, 0, BAR_HEIGHT), + }, + }, + React.createElement(Text, { + expand = false, + text = selectedFiberName or "Selected component", + order = 1, + }), + React.createElement(Text, { + text = "Back to commit view", + onMouseDown = function() + selectFiber(nil, nil) + end, + order = 2, + }) + ), + React.createElement( + Div, + { + name = "content", + -- className = styles.Toolbar, + direction = Enum.FillDirection.Vertical, + }, + React.createElement(WhatChanged, { + fiberID = (selectedFiberID :: any) :: number, + }), + #listItems > 0 + and React.createElement( + Fragment, + {}, + React.createElement(Text, { + expand = false, + text = "Rendered at:", + frameProps = { LayoutOrder = 2 }, + }), + listItems + ), + #listItems == 0 + and React.createElement(Text, { + expand = false, + text = "Did not render during this profiling session.", + frameProps = { LayoutOrder = 2 }, + }) + ) + ) +end +exports.default = SidebarSelectedFiberInfo +exports.SidebarSelectedFiberInfo = SidebarSelectedFiberInfo + +return exports diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/SnapshotCommitList.luau b/packages/react-devtools-shared/src/devtools/views/Profiler/SnapshotCommitList.luau new file mode 100644 index 00000000..e54c6431 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/SnapshotCommitList.luau @@ -0,0 +1,247 @@ +--!strict +-- upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/devtools/views/Profiler/SnapshotCommitList.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) +local Array = LuauPolyfill.Array + +type Map = LuauPolyfill.Map +type Array = LuauPolyfill.Array + +local exports = {} + +local Div = require(script.Parent.Parent.roblox.Div) +local Text = require(script.Parent.Parent.roblox.Text) + +local React = require(Packages.React) +local useEffect = React.useEffect +local useMemo = React.useMemo +local useRef = React.useRef +local useState = React.useState +-- import AutoSizer from 'react-virtualized-auto-sizer'; +-- import {FixedSizeList} from 'react-window'; +local SnapshotCommitListItemModule = require(script.Parent.SnapshotCommitListItem) +local SnapshotCommitListItem = SnapshotCommitListItemModule.default +local minBarWidth = require(script.Parent.constants).minBarWidth + +-- import styles from './SnapshotCommitList.css'; + +-- deviation: ItemData moved to avoid cyclic requires +export type ItemData = SnapshotCommitListItemModule.ItemData +export type DragState = SnapshotCommitListItemModule.DragState + +type Props = { + commitDurations: Array, + commitTimes: Array, + filteredCommitIndices: Array, + selectedCommitIndex: number | nil, + selectedFilteredCommitIndex: number | nil, + selectCommitIndex: (index: number) -> (), +} + +-- deviation: pre-declare functions +local List + +local function SnapshotCommitList(props: Props) + local commitDurations = props.commitDurations + local commitTimes = props.commitTimes + local filteredCommitIndices = props.filteredCommitIndices + local selectedCommitIndex = props.selectedCommitIndex + local selectedFilteredCommitIndex = props.selectedFilteredCommitIndex + local selectCommitIndex = props.selectCommitIndex + + return React.createElement(List, { + commitDurations = commitDurations, + commitTimes = commitTimes, + -- height = height, + filteredCommitIndices = filteredCommitIndices, + selectedCommitIndex = selectedCommitIndex, + selectedFilteredCommitIndex = selectedFilteredCommitIndex, + selectCommitIndex = selectCommitIndex, + -- width = width, + }) + -- return ( + -- + -- {({height, width}) => ( + -- + -- )} + -- + -- ); +end +exports.SnapshotCommitList = SnapshotCommitList +exports.default = SnapshotCommitList + +type ListProps = { + commitDurations: Array, + commitTimes: Array, + -- height: number, + filteredCommitIndices: Array, + selectedCommitIndex: number | nil, + selectedFilteredCommitIndex: number | nil, + selectCommitIndex: (index: number) -> (), + -- width: number, +} + +function List(props: ListProps) + local commitDurations = props.commitDurations + local selectedCommitIndex = props.selectedCommitIndex + local commitTimes = props.commitTimes + -- local height = props.height + local filteredCommitIndices = props.filteredCommitIndices + local selectedFilteredCommitIndex = props.selectedFilteredCommitIndex + local selectCommitIndex = props.selectCommitIndex + -- local width = props.width + + local listRef = useRef(nil :: FixedSizeList | null) + local divRef = useRef(nil :: HTMLDivElement | nil) + local prevCommitIndexRef = useRef(nil :: number | nil) + + -- Make sure a newly selected snapshot is fully visible within the list. + useEffect(function() + if selectedFilteredCommitIndex ~= prevCommitIndexRef.current then + prevCommitIndexRef.current = selectedFilteredCommitIndex + if selectedFilteredCommitIndex ~= nil and listRef.current ~= nil then + listRef.current.scrollToItem(selectedFilteredCommitIndex) + end + end + end, { listRef :: any, selectedFilteredCommitIndex }) + + -- local itemSize = useMemo(function() + -- return math.max(minBarWidth, width / #filteredCommitIndices) + -- end, { filteredCommitIndices :: any, width }) + local maxDuration = useMemo(function() + return Array.reduce(commitDurations, function(max, duration) + return math.max(max, duration) + end, 0) + end, { commitDurations }) + + -- deviation: use array length because of 1-indexing + local maxCommitIndex = #filteredCommitIndices + + local dragState, setDragState = useState(nil :: DragState | nil) + + local function handleDragCommit(dragInfo: any) + local buttons = dragInfo.buttons + local pageX = dragInfo.pageX + if buttons == 0 then + setDragState(nil) + return + end + + if dragState ~= nil then + local commitIndex = dragState.commitIndex + local left = dragState.left + local sizeIncrement = dragState.sizeIncrement + + local newCommitIndex = commitIndex + local newCommitLeft = left + + if pageX < newCommitLeft then + while pageX < newCommitLeft do + newCommitLeft -= sizeIncrement + newCommitIndex -= 1 + end + else + local newCommitRectRight = newCommitLeft + sizeIncrement + while pageX > newCommitRectRight do + newCommitRectRight += sizeIncrement + newCommitIndex += 1 + end + end + + -- todo: adjust indexes for 1-indexing + if newCommitIndex < 0 then + newCommitIndex = 0 + elseif newCommitIndex > maxCommitIndex then + newCommitIndex = maxCommitIndex + end + + selectCommitIndex(newCommitIndex) + end + end + + useEffect(function() + if dragState == nil then + return + end + + local element = divRef.current + if element ~= nil then + local ownerDocument = element.ownerDocument + ownerDocument.addEventListener("mousemove", handleDragCommit) + return function() + ownerDocument.removeEventListener("mousemove", handleDragCommit) + end + end + end, { dragState }) + + -- Pass required contextual data down to the ListItem renderer. + local itemData = useMemo(function(): ItemData + return { + commitDurations = commitDurations, + commitTimes = commitTimes, + filteredCommitIndices = filteredCommitIndices, + maxDuration = maxDuration, + selectedCommitIndex = selectedCommitIndex, + selectedFilteredCommitIndex = selectedFilteredCommitIndex, + selectCommitIndex = selectCommitIndex, + startCommitDrag = setDragState :: any, + } + end, { + commitDurations :: any, + commitTimes, + filteredCommitIndices, + maxDuration, + selectedCommitIndex, + selectedFilteredCommitIndex, + selectCommitIndex, + }) + + local items = {} + for i = 1, #filteredCommitIndices do + items[i] = React.createElement(SnapshotCommitListItem, { + key = tostring(i), + data = itemData, + index = i, + }) + end + return React.createElement(Div, { + direction = Enum.FillDirection.Horizontal, + layoutProps = { + VerticalAlignment = Enum.VerticalAlignment.Bottom, + }, + }, items) + -- return ( + --
+ -- + --
+ -- ); +end + +return exports diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/SnapshotCommitListItem.luau b/packages/react-devtools-shared/src/devtools/views/Profiler/SnapshotCommitListItem.luau new file mode 100644 index 00000000..4f63eccc --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/SnapshotCommitListItem.luau @@ -0,0 +1,164 @@ +--!strict +-- upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/devtools/views/Profiler/SnapshotCommitListItem.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) +local Array = LuauPolyfill.Array + +type Map = LuauPolyfill.Map +type Array = LuauPolyfill.Array +type Object = LuauPolyfill.Object + +local exports = {} + +local Div = require(script.Parent.Parent.roblox.Div) +local Text = require(script.Parent.Parent.roblox.Text) + +local React = require(Packages.React) +local ReactRoblox = require(Packages.ReactRoblox) +local memo = React.memo + +-- import {areEqual} from 'react-window'; +local utils = require(script.Parent.utils) +local getGradientColor = utils.getGradientColor +local formatDuration = utils.formatDuration +local formatTime = utils.formatTime + +-- import styles from './SnapshotCommitListItem.css'; + +-- deviation: define DragState and ItemData here to avoid cyclic requires +export type DragState = { + commitIndex: number, + left: number, + sizeIncrement: number, +} +export type ItemData = { + commitDurations: Array, + commitTimes: Array, + filteredCommitIndices: Array, + maxDuration: number, + selectedCommitIndex: number | nil, + selectedFilteredCommitIndex: number | nil, + selectCommitIndex: (index: number) -> (), + startCommitDrag: (newDragState: DragState) -> (), +} + +type Props = { + data: ItemData, + index: number, + -- style: Object, + -- ... +} + +local function SnapshotCommitListItem(props: Props) + local itemData = props.data + local index = props.index + -- local style = props.style + + local commitDurations = itemData.commitDurations + local commitTimes = itemData.commitTimes + local filteredCommitIndices = itemData.filteredCommitIndices + local maxDuration = itemData.maxDuration + local selectedCommitIndex = itemData.selectedCommitIndex + local selectCommitIndex = itemData.selectCommitIndex + local startCommitDrag = itemData.startCommitDrag + + index = filteredCommitIndices[index] + + local commitDuration = commitDurations[index] + local commitTime = commitTimes[index] + + -- Guard against commits with duration 0 + -- deviation: guard against maxDuration with if + local percentage = math.min(1, math.max(0, if maxDuration == 0 then 0 else commitDuration / maxDuration)) + local isSelected = selectedCommitIndex == index + + -- Leave a 1px gap between snapshots + -- local width = parseFloat(style.width) - 1; + + local function handleMouseDown(event) + -- local buttons = event.buttons + -- local target = event.target + -- if buttons == 1 then + selectCommitIndex(index) + -- startCommitDrag({ + -- commitIndex = index, + -- left = target.getBoundingClientRect().left, + -- sizeIncrement = parseFloat(style.width), + -- }) + -- end + end + + local mouseInside, setMouseInside = React.useState(false) + + local function handleMouseEnter(_) + setMouseInside(true) + end + + local function handleMouseLeave(_) + setMouseInside(false) + end + + return React.createElement( + "TextButton", + { + BorderSizePixel = 0, + BackgroundTransparency = 1, + Size = UDim2.new(1 / #filteredCommitIndices, 0, 1, 0), + LayoutOrder = index, + Text = "", + [ReactRoblox.Event.Activated] = handleMouseDown, + [ReactRoblox.Event.MouseEnter] = handleMouseEnter, + [ReactRoblox.Event.MouseLeave] = handleMouseLeave, + }, + React.createElement("Frame", { + Active = false, + BorderMode = Enum.BorderMode.Inset, + BorderSizePixel = if isSelected then 2 elseif mouseInside then 1 else 0, + BorderColor3 = Color3.new(0, 0, 0), + AnchorPoint = Vector2.new(0, 1), + Position = UDim2.new(0, 0, 1, 0), + BackgroundTransparency = if isSelected or mouseInside then 0 else 0.4, + BackgroundColor3 = if percentage > 0 then getGradientColor(percentage) else Color3.new(0, 0, 0), + Size = UDim2.new(1, 0, percentage, 0), + }) + ) + -- return ( + --
+ --
0 ? getGradientColor(percentage) : undefined, + -- }} + -- /> + --
+ -- ); +end + +-- todo: +-- exports.default = memo(SnapshotCommitListItem, areEqual); +exports.default = SnapshotCommitListItem +exports.SnapshotCommitListItem = exports.default + +return exports diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/SnapshotSelector.luau b/packages/react-devtools-shared/src/devtools/views/Profiler/SnapshotSelector.luau new file mode 100644 index 00000000..193522f9 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/SnapshotSelector.luau @@ -0,0 +1,276 @@ +--!strict +-- upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/devtools/views/Profiler/SnapshotSelector.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) +local Array = LuauPolyfill.Array + +local function String_padStart(value: string, targetLength: number, padString: string?) + local length = string.len(value) + if targetLength <= length then + return value + end + local padString = if padString == nil then " " else padString + targetLength = targetLength - length + + local padStringLength = string.len(padString) + if targetLength > padStringLength then + padString ..= string.rep(padString, targetLength / padStringLength) + end + + return LuauPolyfill.String.slice(padString, 1, targetLength) .. value +end + +type Map = LuauPolyfill.Map +type Array = LuauPolyfill.Array + +local exports = {} + +local Div = require(script.Parent.Parent.roblox.Div) +local Text = require(script.Parent.Parent.roblox.Text) + +local React = require(Packages.React) +local Fragment = React.Fragment +local useCallback = React.useCallback +local useContext = React.useContext +local useMemo = React.useMemo + +-- local Button = require(script.Parent.Parent.Button) +-- local ButtonIcon = require(script.Parent.Parent.ButtonIcon) +local ProfilerContextModule = require(script.Parent.ProfilerContext) +local ProfilerContext = ProfilerContextModule.ProfilerContext +local SnapshotCommitList = require(script.Parent.SnapshotCommitList).default +local maxBarWidth = require(script.Parent.constants).maxBarWidth +local contextModule = require(script.Parent.Parent.context) +local StoreContext = contextModule.StoreContext + +-- import styles from './SnapshotSelector.css'; + +type Props = {} + +local function SnapshotSelector(_: Props) + local profilerValue = useContext(ProfilerContext) + local isCommitFilterEnabled = profilerValue.isCommitFilterEnabled + local minCommitDuration = profilerValue.minCommitDuration + local rootID = profilerValue.rootID + local selectedCommitIndex = profilerValue.selectedCommitIndex + local selectCommitIndex = profilerValue.selectCommitIndex + + local profilerStore = useContext(StoreContext):getProfilerStore() + -- deviation: if rootID is nil, skip getting the commit data to avoid an error + local commitData = if rootID == nil then {} else profilerStore:getDataForRoot((rootID :: any) :: number).commitData + + local commitDurations: Array = {} + local commitTimes: Array = {} + Array.forEach(commitData, function(commitDatum) + table.insert(commitDurations, commitDatum.duration) + table.insert(commitTimes, commitDatum.timestamp) + end) + + local filteredCommitIndices = useMemo(function() + return Array.reduce(commitData, function(reduced, commitDatum, index) + if not isCommitFilterEnabled or commitDatum.duration >= minCommitDuration then + table.insert(reduced, index) + end + return reduced + end, {}) + end, { commitData :: any, isCommitFilterEnabled, minCommitDuration }) + + local numFilteredCommits = #filteredCommitIndices + + -- Map the (unfiltered) selected commit index to an index within the filtered data. + local selectedFilteredCommitIndex = useMemo(function() + if selectedCommitIndex ~= nil then + for i = 1, #filteredCommitIndices do + if filteredCommitIndices[i] == selectedCommitIndex then + return i + end + end + end + -- deviation: in JS, `null + 1` gives `1`, so to imitate in Luau, use 0 + return 0 + end, { filteredCommitIndices :: any, selectedCommitIndex }) + + -- TODO (ProfilerContext) This should be managed by the context controller (reducer). + -- It doesn't currently know about the filtered commits though (since it doesn't suspend). + -- Maybe this component should pass filteredCommitIndices up? + -- deviation: wrap side-effects in useEffect + React.useEffect(function() + -- deviation: instead of using null, we are using 0 + if selectedFilteredCommitIndex == 0 then + if numFilteredCommits > 0 then + -- deviation: first index is 1 in Luau + selectCommitIndex(1) + else + selectCommitIndex(nil) + end + -- deviation: use > because of 1-indexing + elseif selectedFilteredCommitIndex > numFilteredCommits then + selectCommitIndex(if numFilteredCommits == 0 then nil else numFilteredCommits - 1) + end + end, { selectedFilteredCommitIndex :: any, selectCommitIndex, numFilteredCommits }) + + local label = nil + if numFilteredCommits > 0 then + -- deviation: no need to add 1 because of 1-indexing + label = String_padStart(`{selectedFilteredCommitIndex}`, #`{numFilteredCommits}`, "0") + .. " / " + .. numFilteredCommits + end + + local viewNextCommit = useCallback(function() + local nextCommitIndex = ((selectedFilteredCommitIndex :: any) :: number) + 1 + -- deviation: use > because of 1-indexing + if nextCommitIndex > #filteredCommitIndices then + -- deviation: use 1 because of 1-indexing + nextCommitIndex = 1 + end + selectCommitIndex(filteredCommitIndices[nextCommitIndex]) + end, { selectedFilteredCommitIndex :: any, filteredCommitIndices, selectCommitIndex }) + local viewPrevCommit = useCallback(function() + local nextCommitIndex = ((selectedFilteredCommitIndex :: any) :: number) - 1 + -- deviation: use 1 because of 1-indexing + if nextCommitIndex < 1 then + -- deviation: use array length because of 1-indexing + nextCommitIndex = #filteredCommitIndices + end + selectCommitIndex(filteredCommitIndices[nextCommitIndex]) + end, { selectedFilteredCommitIndex :: any, filteredCommitIndices, selectCommitIndex }) + + local handleKeyDown = useCallback(function(event) + -- switch (event.key) { + -- case 'ArrowLeft': + -- viewPrevCommit(); + -- event.stopPropagation(); + -- break; + -- case 'ArrowRight': + -- viewNextCommit(); + -- event.stopPropagation(); + -- break; + -- default: + -- break; + -- } + end, { viewNextCommit, viewPrevCommit }) + + if #commitData == 0 then + return (nil :: any) :: React.ReactElement + end + + -- shift content after tab bar + local layoutOrderBase = 100 + + return React.createElement( + Fragment, + nil, + React.createElement(Text, { + text = label, + frameProps = { LayoutOrder = layoutOrderBase }, + }), + React.createElement( + Text, -- Button, + { + name="previous-button", + -- className = styles.Button, + disabled = numFilteredCommits == 0, + onMouseDown = viewPrevCommit, + text = "<", + frameProps = { LayoutOrder = layoutOrderBase + 1 }, + } + -- React.createElement(ButtonIcon, { type = "previous" }) + ), + React.createElement( + Div, + { + -- className = styles.Commits, + -- onKeyDown = handleKeyDown, + -- style = { + -- flex = if numFilteredCommits > 0 then "1 1 auto" else "0 0 auto", + maxWidth = if numFilteredCommits > 0 then numFilteredCommits * maxBarWidth else nil, + -- }, + -- tabIndex = 0, + frameProps = { LayoutOrder = layoutOrderBase + 2 }, + }, + -- + numFilteredCommits > 0 + and React.createElement(SnapshotCommitList, { + commitDurations = commitDurations, + commitTimes = commitTimes, + filteredCommitIndices = filteredCommitIndices, + selectedCommitIndex = selectedCommitIndex, + selectedFilteredCommitIndex = selectedFilteredCommitIndex, + selectCommitIndex = selectCommitIndex, + }), + numFilteredCommits == 0 + and React.createElement(Div, { + -- className = styles.NoCommits, + }, React.createElement(Text, { text = "No commits" })) + ), + React.createElement( + Text, --Button, + { + name="next-button", + -- className = styles.Button, + disabled = numFilteredCommits == 0, + onMouseDown = viewNextCommit, + text = ">", + frameProps = { LayoutOrder = layoutOrderBase + 3 }, + } + -- React.createElement(ButtonIcon, { type = "next" }) + ) + ) + -- ( + -- + -- {label} + -- + --
0 ? '1 1 auto' : '0 0 auto', + -- maxWidth: + -- numFilteredCommits > 0 + -- ? numFilteredCommits * maxBarWidth + -- : undefined, + -- }} + -- tabIndex={0}> + -- {numFilteredCommits > 0 && ( + -- + -- )} + -- {numFilteredCommits === 0 && ( + --
No commits
+ -- )} + --
+ -- + --
+ -- ); +end +exports.default = SnapshotSelector +exports.SnapshotSelector = SnapshotSelector + +return exports diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/WhatChanged.luau b/packages/react-devtools-shared/src/devtools/views/Profiler/WhatChanged.luau new file mode 100644 index 00000000..785c443d --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/WhatChanged.luau @@ -0,0 +1,264 @@ +--!strict +-- upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/devtools/views/Profiler/WhatChanged.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) +local Array = LuauPolyfill.Array +type Map = LuauPolyfill.Map +type Array = LuauPolyfill.Array + +local exports = {} + +local Div = require(script.Parent.Parent.roblox.Div) +local Text = require(script.Parent.Parent.roblox.Text) + +local React = require(Packages.React) +local useContext = React.useContext +local ProfilerContextModule = require(script.Parent.ProfilerContext) +local ProfilerContext = ProfilerContextModule.ProfilerContext +local contextModule = require(script.Parent.Parent.context) +local StoreContext = contextModule.StoreContext + +-- import styles from './WhatChanged.css'; + +type Props = { + fiberID: number, +} + +local function WhatChanged(props: Props): React.ReactElement? + local fiberID = props.fiberID + local profilerStore = useContext(StoreContext):getProfilerStore() + local profilerContext = useContext(ProfilerContext) + local rootID = profilerContext.rootID + local selectedCommitIndex = profilerContext.selectedCommitIndex + + -- TRICKY + -- Handle edge case where no commit is selected because of a min-duration filter update. + -- If the commit index is null, suspending for data below would throw an error. + -- TODO (ProfilerContext) This check should not be necessary. + if selectedCommitIndex == nil then + return nil + end + + local commitData = profilerStore:getCommitData((rootID :: any) :: number, selectedCommitIndex) + local changeDescriptions = commitData.changeDescriptions + + if changeDescriptions == nil then + return nil + end + + local changeDescription = changeDescriptions:get(fiberID) + if changeDescription == nil then + return nil + end + + if changeDescription.isFirstMount then + return React.createElement( + Div, + { + name = "what-changed", + frameProps = { + AutomaticSize = Enum.AutomaticSize.Y, + }, + }, + React.createElement(Text, { + expand = false, + text = "Why did this render?", + fontSize = 16, + order = 1, + }), + React.createElement(Text, { + expand = false, + text = "This is the first time the component rendered.", + order = 2, + }) + ) + end + + local changes = {} + + if changeDescription.context == true then + table.insert( + changes, + React.createElement( + Div, + { + key = "context", + name = "context-changed", + frameProps = { + AutomaticSize = Enum.AutomaticSize.Y, + }, + }, + React.createElement(Text, { + order = 1, + expand = false, + fontSize = 14, + text = "• Context changed:", + }) + ) + ) + elseif + type(changeDescription.context) == "table" + and changeDescription.context ~= nil + and #changeDescription.context ~= 0 + then + table.insert( + changes, + React.createElement( + Div, + { + key = "context", + name = "context-changed", + frameProps = { + AutomaticSize = Enum.AutomaticSize.Y, + }, + }, + React.createElement(Text, { + order = 1, + expand = false, + fontSize = 14, + text = "• Context changed:", + }), + Array.map(changeDescription.context, function(key, index): any + return React.createElement(Text, { + order = 1 + index, + key = key, + expand = false, + text = key, + }) + end) + ) + ) + end + + if changeDescription.didHooksChange then + table.insert( + changes, + React.createElement( + Div, + { + key = "hooks", + name = "hooks-changed", + frameProps = { + AutomaticSize = Enum.AutomaticSize.Y, + }, + }, + React.createElement(Text, { + order = 1, + expand = false, + fontSize = 14, + text = "• Hooks changed:", + }) + ) + ) + end + + if changeDescription.props ~= nil and #changeDescription.props ~= 0 then + table.insert( + changes, + React.createElement( + Div, + { + key = "props", + name = "props-changed", + frameProps = { + AutomaticSize = Enum.AutomaticSize.Y, + }, + }, + React.createElement(Text, { + order = 1, + expand = false, + fontSize = 14, + text = "• Props changed:", + }), + Array.map(changeDescription.props, function(key, index): any + return React.createElement(Text, { + order = 1 + index, + key = key, + expand = false, + text = key, + }) + end) + ) + ) + end + + if changeDescription.state ~= nil and #changeDescription.state ~= 0 then + table.insert( + changes, + React.createElement( + Div, + { + key = "state", + name = "state-changed", + frameProps = { + AutomaticSize = Enum.AutomaticSize.Y, + }, + }, + React.createElement(Text, { + order = 1, + expand = false, + fontSize = 14, + text = "• State changed:", + }), + Array.map(changeDescription.state, function(key, index): any + return React.createElement(Text, { + order = 1 + index, + key = key, + expand = false, + text = key, + }) + end) + ) + ) + end + + if #changes == 0 then + table.insert( + changes, + React.createElement( + Div, + { + key = "nothing", + name = "nothing", + frameProps = { + AutomaticSize = Enum.AutomaticSize.Y, + }, + }, + React.createElement(Text, { + order = 1, + expand = false, + fontSize = 14, + text = "The parent component rendered.", + }) + ) + ) + end + + return React.createElement( + Div, + { + name = "what-changed", + frameProps = { + AutomaticSize = Enum.AutomaticSize.Y, + }, + }, + React.createElement(Text, { + expand = false, + text = "Why did this render?", + }), + changes + ) +end +exports.WhatChanged = WhatChanged +exports.default = WhatChanged + +return exports diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/constants.luau b/packages/react-devtools-shared/src/devtools/views/Profiler/constants.luau new file mode 100644 index 00000000..5222fb9f --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/constants.luau @@ -0,0 +1,18 @@ +--!strict +-- upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/devtools/views/Profiler/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 + ]] + +return { + barWidthThreshold = 2, + interactionCommitSize = 10, + interactionLabelWidth = 200, + maxBarWidth = 30, + minBarWidth = 5, +} diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/utils.luau b/packages/react-devtools-shared/src/devtools/views/Profiler/utils.luau index d5f7d13d..c82e389e 100644 --- a/packages/react-devtools-shared/src/devtools/views/Profiler/utils.luau +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/utils.luau @@ -31,17 +31,19 @@ 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. + Color3.fromHex("#37afa9"), -- "var(--color-commit-gradient-0)", + Color3.fromHex("#63b19e"), -- "var(--color-commit-gradient-1)", + Color3.fromHex("#80b393"), -- "var(--color-commit-gradient-2)", + Color3.fromHex("#97b488"), -- "var(--color-commit-gradient-3)", + Color3.fromHex("#abb67d"), -- "var(--color-commit-gradient-4)", + Color3.fromHex("#beb771"), -- "var(--color-commit-gradient-5)", + Color3.fromHex("#cfb965"), -- "var(--color-commit-gradient-6)", + Color3.fromHex("#dfba57"), -- "var(--color-commit-gradient-7)", + Color3.fromHex("#efbb49"), -- "var(--color-commit-gradient-8)", + Color3.fromHex("#febc38"), -- "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, @@ -210,11 +212,13 @@ local function getGradientColor(value: number) local maxIndex = #commitGradient local index if Number.isNaN(value) then - index = 0 + -- deviation: use 1 because 1-indexing + index = 1 elseif not Number.isFinite(value) then index = maxIndex else - index = math.max(0, math.min(maxIndex, value)) * maxIndex + -- deviation: use 1 because 1-indexing + index = math.max(1, math.min(maxIndex, value)) * maxIndex end return commitGradient[math.round(index)] end diff --git a/packages/react-devtools-shared/src/devtools/views/Settings/ComponentSettings.luau b/packages/react-devtools-shared/src/devtools/views/Settings/ComponentSettings.luau new file mode 100644 index 00000000..ac0b2fdc --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Settings/ComponentSettings.luau @@ -0,0 +1,22 @@ +--!strict +-- upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/devtools/views/Settings/ComponentSettings.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 React = require(Packages.React) + +-- todo + +return exports diff --git a/packages/react-devtools-shared/src/devtools/views/Settings/DebuggingSettings.luau b/packages/react-devtools-shared/src/devtools/views/Settings/DebuggingSettings.luau new file mode 100644 index 00000000..bf9963d9 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Settings/DebuggingSettings.luau @@ -0,0 +1,22 @@ +--!strict +-- upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/devtools/views/Settings/DebuggingSettings.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 React = require(Packages.React) + +-- todo + +return exports diff --git a/packages/react-devtools-shared/src/devtools/views/Settings/GeneralSettings.luau b/packages/react-devtools-shared/src/devtools/views/Settings/GeneralSettings.luau new file mode 100644 index 00000000..0f2f2808 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Settings/GeneralSettings.luau @@ -0,0 +1,22 @@ +--!strict +-- upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/devtools/views/Settings/GeneralSettings.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 React = require(Packages.React) + +-- todo + +return exports diff --git a/packages/react-devtools-shared/src/devtools/views/Settings/ProfilerSettings.luau b/packages/react-devtools-shared/src/devtools/views/Settings/ProfilerSettings.luau new file mode 100644 index 00000000..d57e9df1 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Settings/ProfilerSettings.luau @@ -0,0 +1,144 @@ +--!strict +-- upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/devtools/views/Settings/SettingsModalContext.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 Div = require(script.Parent.Parent.roblox.Div) +local Text = require(script.Parent.Parent.roblox.Text) + +local React = require(Packages.React) +local useCallback = React.useCallback +local useContext = React.useContext +local useMemo = React.useMemo +local useRef = React.useRef + +local useSubscription = require(script.Parent.Parent.hooks).useSubscription +local contextModule = require(script.Parent.Parent.context) +local StoreContext = contextModule.StoreContext +local ProfilerContextModule = require(script.Parent.Parent.Profiler.ProfilerContext) +local ProfilerContext = ProfilerContextModule.ProfilerContext + +-- import styles from './SettingsShared.css'; + +local function ProfilerSettings(_: {}) + local profilerValue = useContext(ProfilerContext) + local isCommitFilterEnabled = profilerValue.isCommitFilterEnabled + local minCommitDuration = profilerValue.minCommitDuration + local setIsCommitFilterEnabled = profilerValue.setIsCommitFilterEnabled + local setMinCommitDuration = profilerValue.setMinCommitDuration + local store = useContext(StoreContext) + + local recordChangeDescriptionsSubscription = useMemo(function() + return { + getCurrentValue = function() + return store:getRecordChangeDescriptions() + end, + subscribe = function(callback: () -> ()) + store:addListener("recordChangeDescriptions", callback) + return function() + return store:removeListener("recordChangeDescriptions", callback) + end + end, + } + end, { store }) + + local recordChangeDescriptions = useSubscription(recordChangeDescriptionsSubscription) + + local updateRecordChangeDescriptions = useCallback(function(checked: boolean) + -- local currentTarget = info.currentTarget + store:setRecordChangeDescriptions(checked) + end, { store }) + -- local updateMinCommitDuration = useCallback(function(event: SyntheticEvent) + -- local newValue = parseFloat(event.currentTarget.value) + -- setMinCommitDuration(Number.isNaN(newValue) or if newValue <= 0 then 0 else newValue) + -- end, { setMinCommitDuration }) + + -- deviation: re-order minCommitDurationInputRef + local minCommitDurationInputRef = useRef(nil :: HTMLInputElement | nil) + + local updateIsCommitFilterEnabled = useCallback(function(event: SyntheticEvent) + local checked = event.currentTarget.checked + setIsCommitFilterEnabled(checked) + if checked then + if minCommitDurationInputRef.current ~= nil then + minCommitDurationInputRef.current.focus() + end + end + end, { setIsCommitFilterEnabled }) + + return React.createElement( + Div, + { + name = "profiler-settings", + direction = Enum.FillDirection.Vertical, + }, + React.createElement( + Div, + { + name = "record-change-descriptions", + direction = Enum.FillDirection.Horizontal, + frameProps = { AutomaticSize = Enum.AutomaticSize.Y }, + }, + React.createElement(Text, { + expand = false, + text = tostring(recordChangeDescriptions), + onMouseDown = function() + updateRecordChangeDescriptions(not recordChangeDescriptions) + end, + }), + React.createElement(Text, { + expand = false, + text = "Record why each component rendered while profiling.", + }) + ) + ) + -- return ( + --
+ --
+ -- + --
+ + --
+ -- {' '} + -- {' '} + -- (ms) + --
+ --
+ -- ); +end +exports.ProfilerSettings = ProfilerSettings +exports.default = ProfilerSettings + +return exports diff --git a/packages/react-devtools-shared/src/devtools/views/Settings/SettingsContext.luau b/packages/react-devtools-shared/src/devtools/views/Settings/SettingsContext.luau new file mode 100644 index 00000000..d0f446ba --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Settings/SettingsContext.luau @@ -0,0 +1,350 @@ +--!strict +-- upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/devtools/views/Settings/SettingsContext.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 React = require(Packages.React) +local createContext = React.createContext +local useContext = React.useContext +local useEffect = React.useEffect +local useLayoutEffect = React.useLayoutEffect +local useMemo = React.useMemo + +local constants = require(script.Parent.Parent.Parent.Parent.constants) +local COMFORTABLE_LINE_HEIGHT = constants.COMFORTABLE_LINE_HEIGHT +local COMPACT_LINE_HEIGHT = constants.COMPACT_LINE_HEIGHT +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 LOCAL_STORAGE_TRACE_UPDATES_ENABLED_KEY = constants.LOCAL_STORAGE_TRACE_UPDATES_ENABLED_KEY + +local hooksModule = require(script.Parent.Parent.hooks) +local useLocalStorage = hooksModule.useLocalStorage +local contextsModule = require(script.Parent.Parent.context) +local BridgeContext = contextsModule.BridgeContext + +export type DisplayDensity = "comfortable" | "compact" +export type Theme = "auto" | "light" | "dark" + +type Context = { + displayDensity: DisplayDensity, + setDisplayDensity: (value: DisplayDensity) -> (), + + -- Derived from display density. + -- Specified as a separate prop so it can trigger a re-render of FixedSizeList. + lineHeight: number, + + appendComponentStack: boolean, + setAppendComponentStack: (value: boolean) -> (), + + breakOnConsoleErrors: boolean, + setBreakOnConsoleErrors: (value: boolean) -> (), + + theme: Theme, + setTheme: (value: Theme) -> (), + + traceUpdatesEnabled: boolean, + setTraceUpdatesEnabled: (value: boolean) -> (), +} + +local SettingsContext = createContext((nil :: any) :: Context) +SettingsContext.displayName = "SettingsContext" + +type DocumentElements = Array + +type Props = { + -- browserTheme: BrowserTheme, + children: React.ReactNode, + -- componentsPortalContainer: Element?, + -- profilerPortalContainer: Element?, +} + +local function SettingsContextController(props: Props) + -- local browserTheme = props.browserTheme + local children = props.children + -- local componentsPortalContainer = props.componentsPortalContainer + -- local profilerPortalContainer = props.profilerPortalContainer + local bridge = useContext(BridgeContext) + + local displayDensity: DisplayDensity, setDisplayDensity = + useLocalStorage("React::DevTools::displayDensity", "compact" :: DisplayDensity) + local theme: Theme, setTheme = useLocalStorage("React::DevTools::theme", "auto" :: Theme) + + local appendComponentStack, setAppendComponentStack = useLocalStorage(LOCAL_STORAGE_SHOULD_PATCH_CONSOLE_KEY, true) + local breakOnConsoleErrors, setBreakOnConsoleErrors = + useLocalStorage(LOCAL_STORAGE_SHOULD_BREAK_ON_CONSOLE_ERRORS, false) + local traceUpdatesEnabled, setTraceUpdatesEnabled = useLocalStorage(LOCAL_STORAGE_TRACE_UPDATES_ENABLED_KEY, false) + + -- local documentElements: DocumentElements = useMemo(function() + -- local array: Array = { + -- document.documentElement :: any, + -- } + -- if componentsPortalContainer ~= nil then + -- table.insert(array, componentsPortalContainer.ownerDocument.documentElement :: any) + -- end + -- if profilerPortalContainer ~= nil then + -- table.insert(array, profilerPortalContainer.ownerDocument.documentElement :: any) + -- end + -- return array + -- end, { componentsPortalContainer, profilerPortalContainer }) + + -- useLayoutEffect(() => { + -- switch (displayDensity) { + -- case 'comfortable': + -- updateDisplayDensity('comfortable', documentElements); + -- break; + -- case 'compact': + -- updateDisplayDensity('compact', documentElements); + -- break; + -- default: + -- throw Error(`Unsupported displayDensity value "${displayDensity}"`); + -- } + -- }, {displayDensity, documentElements}); + + -- useLayoutEffect(() => { + -- switch (theme) { + -- case 'light': + -- updateThemeVariables('light', documentElements); + -- break; + -- case 'dark': + -- updateThemeVariables('dark', documentElements); + -- break; + -- case 'auto': + -- updateThemeVariables(browserTheme, documentElements); + -- break; + -- default: + -- throw Error(`Unsupported theme value "${theme}"`); + -- } + -- }, {browserTheme, theme, documentElements}); + + useEffect(function() + bridge:send("updateConsolePatchSettings", { + appendComponentStack = appendComponentStack, + breakOnConsoleErrors = breakOnConsoleErrors, + }) + end, { bridge :: any, appendComponentStack, breakOnConsoleErrors }) + + useEffect(function() + bridge:send("setTraceUpdatesEnabled", traceUpdatesEnabled) + end, { bridge :: any, traceUpdatesEnabled }) + + local value = useMemo(function() + return { + appendComponentStack = appendComponentStack, + breakOnConsoleErrors = breakOnConsoleErrors, + displayDensity = displayDensity, + lineHeight = if displayDensity == "compact" then COMPACT_LINE_HEIGHT else COMFORTABLE_LINE_HEIGHT, + setAppendComponentStack = setAppendComponentStack, + setBreakOnConsoleErrors = setBreakOnConsoleErrors, + setDisplayDensity = setDisplayDensity, + setTheme = setTheme, + setTraceUpdatesEnabled = setTraceUpdatesEnabled, + theme = theme, + traceUpdatesEnabled = traceUpdatesEnabled, + } + end, { + appendComponentStack, + breakOnConsoleErrors, + -- displayDensity, + -- setAppendComponentStack, + -- setBreakOnConsoleErrors, + -- setDisplayDensity, + -- setTheme, + -- setTraceUpdatesEnabled, + -- theme, + traceUpdatesEnabled, + }) + + return React.createElement(SettingsContext.Provider, { + value = value, + }, children) +end +exports.SettingsContextController = SettingsContextController + +-- local function setStyleVariable(name: string, value: string, documentElements: DocumentElements) +-- documentElements:forEach(function(documentElement) +-- documentElement.style.setProperty(name, value) +-- end) +-- end + +-- local function updateStyleHelper(themeKey: string, style: string, documentElements: DocumentElements) +-- setStyleVariable(`--${style}`, `var(--${themeKey}-${style})`, documentElements) +-- end + +local function updateDisplayDensity(displayDensity: DisplayDensity, documentElements: DocumentElements): () + -- updateStyleHelper( + -- displayDensity, + -- 'font-size-monospace-normal', + -- documentElements, + -- ); + -- updateStyleHelper( + -- displayDensity, + -- 'font-size-monospace-large', + -- documentElements, + -- ); + -- updateStyleHelper( + -- displayDensity, + -- 'font-size-monospace-small', + -- documentElements, + -- ); + -- updateStyleHelper(displayDensity, 'font-size-sans-normal', documentElements); + -- updateStyleHelper(displayDensity, 'font-size-sans-large', documentElements); + -- updateStyleHelper(displayDensity, 'font-size-sans-small', documentElements); + -- updateStyleHelper(displayDensity, 'line-height-data', documentElements); + + -- -- Sizes and paddings/margins are all rem-based, + -- -- so update the root font-size as well when the display preference changes. + -- const computedStyle = getComputedStyle((document.body: any)); + -- const fontSize = computedStyle.getPropertyValue( + -- `--${displayDensity}-root-font-size`, + -- ); + -- const root = ((document.querySelector(':root'): any): HTMLElement); + -- root.style.fontSize = fontSize; +end + +local function updateThemeVariables(theme: Theme, documentElements: DocumentElements): () + -- updateStyleHelper(theme, 'color-attribute-name', documentElements); + -- updateStyleHelper( + -- theme, + -- 'color-attribute-name-not-editable', + -- documentElements, + -- ); + -- updateStyleHelper(theme, 'color-attribute-name-inverted', documentElements); + -- updateStyleHelper(theme, 'color-attribute-value', documentElements); + -- updateStyleHelper(theme, 'color-attribute-value-inverted', documentElements); + -- updateStyleHelper(theme, 'color-attribute-editable-value', documentElements); + -- updateStyleHelper(theme, 'color-background', documentElements); + -- updateStyleHelper(theme, 'color-background-hover', documentElements); + -- updateStyleHelper(theme, 'color-background-inactive', documentElements); + -- updateStyleHelper(theme, 'color-background-invalid', documentElements); + -- updateStyleHelper(theme, 'color-background-selected', documentElements); + -- updateStyleHelper(theme, 'color-border', documentElements); + -- updateStyleHelper(theme, 'color-button-background', documentElements); + -- updateStyleHelper(theme, 'color-button-background-focus', documentElements); + -- updateStyleHelper(theme, 'color-button', documentElements); + -- updateStyleHelper(theme, 'color-button-active', documentElements); + -- updateStyleHelper(theme, 'color-button-disabled', documentElements); + -- updateStyleHelper(theme, 'color-button-focus', documentElements); + -- updateStyleHelper(theme, 'color-button-hover', documentElements); + -- updateStyleHelper( + -- theme, + -- 'color-commit-did-not-render-fill', + -- documentElements, + -- ); + -- updateStyleHelper( + -- theme, + -- 'color-commit-did-not-render-fill-text', + -- documentElements, + -- ); + -- updateStyleHelper( + -- theme, + -- 'color-commit-did-not-render-pattern', + -- documentElements, + -- ); + -- updateStyleHelper( + -- theme, + -- 'color-commit-did-not-render-pattern-text', + -- documentElements, + -- ); + -- updateStyleHelper(theme, 'color-commit-gradient-0', documentElements); + -- updateStyleHelper(theme, 'color-commit-gradient-1', documentElements); + -- updateStyleHelper(theme, 'color-commit-gradient-2', documentElements); + -- updateStyleHelper(theme, 'color-commit-gradient-3', documentElements); + -- updateStyleHelper(theme, 'color-commit-gradient-4', documentElements); + -- updateStyleHelper(theme, 'color-commit-gradient-5', documentElements); + -- updateStyleHelper(theme, 'color-commit-gradient-6', documentElements); + -- updateStyleHelper(theme, 'color-commit-gradient-7', documentElements); + -- updateStyleHelper(theme, 'color-commit-gradient-8', documentElements); + -- updateStyleHelper(theme, 'color-commit-gradient-9', documentElements); + -- updateStyleHelper(theme, 'color-commit-gradient-text', documentElements); + -- updateStyleHelper(theme, 'color-component-name', documentElements); + -- updateStyleHelper(theme, 'color-component-name-inverted', documentElements); + -- updateStyleHelper( + -- theme, + -- 'color-component-badge-background', + -- documentElements, + -- ); + -- updateStyleHelper( + -- theme, + -- 'color-component-badge-background-inverted', + -- documentElements, + -- ); + -- updateStyleHelper(theme, 'color-component-badge-count', documentElements); + -- updateStyleHelper( + -- theme, + -- 'color-component-badge-count-inverted', + -- documentElements, + -- ); + -- updateStyleHelper(theme, 'color-context-background', documentElements); + -- updateStyleHelper(theme, 'color-context-background-hover', documentElements); + -- updateStyleHelper( + -- theme, + -- 'color-context-background-selected', + -- documentElements, + -- ); + -- updateStyleHelper(theme, 'color-context-border', documentElements); + -- updateStyleHelper(theme, 'color-context-text', documentElements); + -- updateStyleHelper(theme, 'color-context-text-selected', documentElements); + -- updateStyleHelper(theme, 'color-dim', documentElements); + -- updateStyleHelper(theme, 'color-dimmer', documentElements); + -- updateStyleHelper(theme, 'color-dimmest', documentElements); + -- updateStyleHelper(theme, 'color-error-background', documentElements); + -- updateStyleHelper(theme, 'color-error-border', documentElements); + -- updateStyleHelper(theme, 'color-error-text', documentElements); + -- updateStyleHelper(theme, 'color-expand-collapse-toggle', documentElements); + -- updateStyleHelper(theme, 'color-link', documentElements); + -- updateStyleHelper(theme, 'color-modal-background', documentElements); + -- updateStyleHelper(theme, 'color-record-active', documentElements); + -- updateStyleHelper(theme, 'color-record-hover', documentElements); + -- updateStyleHelper(theme, 'color-record-inactive', documentElements); + -- updateStyleHelper(theme, 'color-color-scroll-thumb', documentElements); + -- updateStyleHelper(theme, 'color-color-scroll-track', documentElements); + -- updateStyleHelper(theme, 'color-search-match', documentElements); + -- updateStyleHelper(theme, 'color-shadow', documentElements); + -- updateStyleHelper(theme, 'color-search-match-current', documentElements); + -- updateStyleHelper( + -- theme, + -- 'color-selected-tree-highlight-active', + -- documentElements, + -- ); + -- updateStyleHelper( + -- theme, + -- 'color-selected-tree-highlight-inactive', + -- documentElements, + -- ); + -- updateStyleHelper(theme, 'color-tab-selected-border', documentElements); + -- updateStyleHelper(theme, 'color-text', documentElements); + -- updateStyleHelper(theme, 'color-text-invalid', documentElements); + -- updateStyleHelper(theme, 'color-text-selected', documentElements); + -- updateStyleHelper(theme, 'color-toggle-background-invalid', documentElements); + -- updateStyleHelper(theme, 'color-toggle-background-on', documentElements); + -- updateStyleHelper(theme, 'color-toggle-background-off', documentElements); + -- updateStyleHelper(theme, 'color-toggle-text', documentElements); + -- updateStyleHelper(theme, 'color-tooltip-background', documentElements); + -- updateStyleHelper(theme, 'color-tooltip-text', documentElements); + + -- -- Font smoothing varies based on the theme. + -- updateStyleHelper(theme, 'font-smoothing', documentElements); + + -- -- Update scrollbar color to match theme. + -- -- this CSS property is currently only supported in Firefox, + -- -- but it makes a significant UI improvement in dark mode. + -- -- https://developer.mozilla.org/en-US/docs/Web/CSS/scrollbar-color + -- documentElements.forEach(documentElement => { + -- -- $FlowFixMe scrollbarColor is missing in CSSStyleDeclaration + -- documentElement.style.scrollbarColor = `var(${`--${theme}-color-scroll-thumb`}) var(${`--${theme}-color-scroll-track`})`; + -- }); +end + +return exports diff --git a/packages/react-devtools-shared/src/devtools/views/Settings/SettingsModal.luau b/packages/react-devtools-shared/src/devtools/views/Settings/SettingsModal.luau new file mode 100644 index 00000000..8a66201c --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Settings/SettingsModal.luau @@ -0,0 +1,198 @@ +--!strict +-- upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/devtools/views/Settings/SettingsModal.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 Div = require(script.Parent.Parent.roblox.Div) +local Text = require(script.Parent.Parent.roblox.Text) + +local React = require(Packages.React) +local useCallback = React.useCallback +local useContext = React.useContext +local useEffect = React.useEffect +local useMemo = React.useMemo +local useRef = React.useRef + +local SettingsModalContext = require(script.Parent.SettingsModalContext).SettingsModalContext +-- import Button from '../Button'; +-- import ButtonIcon from '../ButtonIcon'; +local TabBarModule = require(script.Parent.Parent.TabBar) +local TabBar = TabBarModule.TabBar +local contextModule = require(script.Parent.Parent.context) +local StoreContext = contextModule.StoreContext +local hooksModule = require(script.Parent.Parent.hooks) +local useLocalStorage = hooksModule.useLocalStorage +local useModalDismissSignal = hooksModule.useModalDismissSignal +local useSubscription = hooksModule.useSubscription +-- local ComponentsSettings =require(script.Parent.ComponentsSettings).default +-- local DebuggingSettings =require(script.Parent.DebuggingSettings).default +-- local GeneralSettings =require(script.Parent.GeneralSettings).default +local ProfilerSettings = require(script.Parent.ProfilerSettings).default + +-- import styles from './SettingsModal.css'; + +type TabID = "general" | "components" | "profiler" + +-- deviation: pre-declaration +local SettingsModalImpl + +local function SettingsModal(_: {}): React.ReactElement? + local settingsModal = useContext(SettingsModalContext) + local isModalShowing = settingsModal.isModalShowing + local setIsModalShowing = settingsModal.setIsModalShowing + local store = useContext(StoreContext) + local profilerStore = store:getProfilerStore() + + -- Updating preferences while profiling is in progress could break things (e.g. filtering) + -- Explicitly disallow it for now. + local isProfilingSubscription = useMemo(function() + return { + getCurrentValue = function() + return profilerStore:isProfiling() + end, + subscribe = function(callback: () -> ()) + profilerStore:addListener("isProfiling", callback) + return function() + profilerStore:removeListener("isProfiling", callback) + end + end, + } + end, { profilerStore }) + local isProfiling: boolean = useSubscription(isProfilingSubscription) + + -- deviation: wrap side effect in useEffect + useEffect(function() + if isProfiling and isModalShowing then + setIsModalShowing(false) + end + end, { isProfiling, isModalShowing }) + + if not isModalShowing then + return nil + end + + return React.createElement(SettingsModalImpl) +end +exports.SettingsModal = SettingsModal +exports.default = SettingsModal + +local BAR_HEIGHT = 40 + +-- pre-declaration +local tabs: Array + +function SettingsModalImpl(_: {}) + local settingsModal = useContext(SettingsModalContext) + local setIsModalShowing = settingsModal.setIsModalShowing + local dismissModal = useCallback(function() + return setIsModalShowing(false) + end, { + setIsModalShowing, + }) + + local selectedTabID, selectTab = useLocalStorage( + "React::DevTools::selectedSettingsTabID", + -- deviation: default to profiler instead of "general" :: TabID + "profiler" :: TabID + ) + + -- local modalRef = useRef(nil :: HTMLDivElement | nil) + -- useModalDismissSignal(modalRef, dismissModal) + + -- useEffect(function() + -- if modalRef.current ~= nil then + -- modalRef.current.focus() + -- end + -- end, { modalRef }) + + local view = nil + if selectedTabID == "components" then + -- view = React.createElement(ComponentsSettings) + elseif selectedTabID == "debugging" then + -- view = React.createElement(DebuggingSettings) + elseif selectedTabID == "general" then + -- view = React.createElement(GeneralSettings) + elseif selectedTabID == "profiler" then + view = React.createElement(ProfilerSettings) + end + + return React.createElement( + Div, + { + name = "settings-modal", + direction = Enum.FillDirection.Vertical, + frameProps = { + BackgroundTransparency = 0, + BackgroundColor3 = Color3.new(0, 0, 0), + Size = UDim2.fromScale(1, 1), + ZIndex = 100, + }, + }, + React.createElement( + Div, + { + name = "settings-toolbar", + direction = Enum.FillDirection.Horizontal, + onClick = function() + dismissModal() + end, + frameProps = { + Size = UDim2.new(1, 0, 0, BAR_HEIGHT), + }, + }, + React.createElement(TabBar, { + currentTab = selectedTabID, + id = "Settings", + selectTab = selectTab, + tabs = tabs, + -- type="settings", + }), + React.createElement(Text, { + order = 10, + text = "Close", + onMouseDown = dismissModal, + }) + ), + React.createElement("Frame", { + LayoutOrder = 2, + Size = UDim2.new(1, 0, 1, -BAR_HEIGHT), + }, view) + ) +end + +tabs = { + { + id = "general", + -- icon = "settings", + label = "General", + }, + { + id = "debugging", + -- icon = "bug", + label = "Debugging", + }, + { + id = "components", + -- icon = "components", + label = "Components", + }, + { + id = "profiler", + -- icon = "profiler", + label = "Profiler", + }, +} + +return exports diff --git a/packages/react-devtools-shared/src/devtools/views/Settings/SettingsModalContext.luau b/packages/react-devtools-shared/src/devtools/views/Settings/SettingsModalContext.luau new file mode 100644 index 00000000..120971eb --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Settings/SettingsModalContext.luau @@ -0,0 +1,52 @@ +--!strict +-- upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/devtools/views/Settings/SettingsModalContext.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 exports = {} + +local React = require(Packages.React) +local createContext = React.createContext +local useMemo = React.useMemo +local useState = React.useState + +export type DisplayDensity = "comfortable" | "compact" +export type Theme = "auto" | "light" | "dark" + +type Context = { + isModalShowing: boolean, + setIsModalShowing: (value: boolean) -> (), +} + +local SettingsModalContext = createContext((nil :: any) :: Context) +SettingsModalContext.displayName = "SettingsModalContext" + +function SettingsModalContextController(props: { children: React.Node }) + local isModalShowing, setIsModalShowing = useState(false) + + local value = useMemo(function() + return { + isModalShowing = isModalShowing, + setIsModalShowing = setIsModalShowing, + } + end, { + isModalShowing :: any, + setIsModalShowing, + }) + + return React.createElement(SettingsModalContext.Provider, { + value = value, + }, props.children) +end + +exports.SettingsModalContext = SettingsModalContext +exports.SettingsModalContextController = SettingsModalContextController + +return exports diff --git a/packages/react-devtools-shared/src/devtools/views/Settings/SettingsModalContextToggle.luau b/packages/react-devtools-shared/src/devtools/views/Settings/SettingsModalContextToggle.luau new file mode 100644 index 00000000..9f1f693c --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Settings/SettingsModalContextToggle.luau @@ -0,0 +1,71 @@ +--!strict +-- upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/devtools/views/Settings/SettingsModalContext.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 Text = require(script.Parent.Parent.roblox.Text) + +local React = require(Packages.React) +local useCallback = React.useCallback +local useContext = React.useContext +local useMemo = React.useMemo +local SettingsModalContext = require(script.Parent.SettingsModalContext).SettingsModalContext +-- import Button from '../Button'; +-- import ButtonIcon from '../ButtonIcon'; +local contextModule = require(script.Parent.Parent.context) +local StoreContext = contextModule.StoreContext +local hooksModule = require(script.Parent.Parent.hooks) +local useSubscription = hooksModule.useSubscription + +local function SettingsModalContextToggle(_: {}) + local settingsContextValue = useContext(SettingsModalContext) + local setIsModalShowing = settingsContextValue.setIsModalShowing + local store = useContext(StoreContext) + local profilerStore = store:getProfilerStore() + + local showFilterModal = useCallback(function() + setIsModalShowing(true) + end, { + setIsModalShowing, + }) + + -- Updating preferences while profiling is in progress could break things (e.g. filtering) + -- Explicitly disallow it for now. + local isProfilingSubscription = useMemo(function() + return { + getCurrentValue = function() + return profilerStore:isProfiling() + end, + subscribe = function(callback: () -> ()) + profilerStore:addListener("isProfiling", callback) + return function() + profilerStore:removeListener("isProfiling", callback) + end + end, + } + end, { profilerStore }) + local isProfiling = useSubscription(isProfilingSubscription) + + return React.createElement(Text, { + name = "settings-modal-toggle", + disabled = isProfiling, + onMouseDown = showFilterModal, + text = "Settings", + }) +end + +exports.SettingsModalContextToggle = SettingsModalContextToggle +exports.default = SettingsModalContextToggle +return exports diff --git a/packages/react-devtools-shared/src/devtools/views/TabBar.luau b/packages/react-devtools-shared/src/devtools/views/TabBar.luau new file mode 100644 index 00000000..e8651238 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/TabBar.luau @@ -0,0 +1,164 @@ +--!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 +local LuauPolyfill = require(Packages.LuauPolyfill) +local Array = LuauPolyfill.Array +type Array = LuauPolyfill.Array +local exports = {} + +local Text = require(script.Parent.roblox.Text) + +local React = require(Packages.React) +local Fragment = React.Fragment +local useCallback = React.useCallback + +-- import Tooltip from '@reach/tooltip'; +-- import Icon from './Icon'; + +-- import styles from './TabBar.css'; +-- import tooltipStyles from './Tooltip.css'; + +-- import type {IconType} from './Icon'; + +export type TabInfo = { + -- icon: IconType, + id: string, + label: string, + title: string?, +} + +export type Props = { + currentTab: any, + disabled: boolean?, + id: string, + selectTab: (tabID: any) -> (), + tabs: Array, + type: "navigation" | "profiler" | "settings", + -- deviation: Roblox view props + startOrderAt: number?, +} + +local function TabBar(props: Props) + local currentTab = props.currentTab + local disabled = if props.disabled == nil then false else props.disabled + local id = props.id + local selectTab = props.selectTab + local tabs = props.tabs + local type = props.type + local startOrderAt = props.startOrderAt or 0 + + -- deviation: wrap side-effects in useEffect + local oneSelected = Array.some(tabs, function(tab) + return tab.id == currentTab + end) + React.useEffect(function() + if not oneSelected then + selectTab(tabs[1].id) + end + end, { oneSelected }) + + local onChange = useCallback(function(payload) + local currentTarget = payload.currentTarget + return selectTab(currentTarget.value) + end, { selectTab }) + + local handleKeyDown = useCallback(function(event) + -- switch (event.key) { + -- case 'ArrowDown': + -- case 'ArrowLeft': + -- case 'ArrowRight': + -- case 'ArrowUp': + -- event.stopPropagation(); + -- break; + -- default: + -- break; + -- } + end, {}) + + -- local iconSizeClassName + -- local tabLabelClassName + -- local tabSizeClassName + -- if type == "navigation" then + -- iconSizeClassName = styles.IconSizeNavigation + -- tabLabelClassName = styles.TabLabelNavigation + -- tabSizeClassName = styles.TabSizeNavigation + -- elseif type == "profiler" then + -- iconSizeClassName = styles.IconSizeProfiler + -- tabLabelClassName = styles.TabLabelProfiler + -- tabSizeClassName = styles.TabSizeProfiler + -- elseif type == "settings" then + -- iconSizeClassName = styles.IconSizeSettings + -- tabLabelClassName = styles.TabLabelSettings + -- tabSizeClassName = styles.TabSizeSettings + -- else + -- error(Error.new(`Unsupported type "{type}"`)) + -- end + + return React.createElement( + Fragment, + nil, + Array.map(tabs, function(tab: TabInfo, index: number): any + -- local icon = tab.icon + local id = tab.id + local label = tab.label + -- local title = tab.title + + local button = React.createElement(Text, { + -- className = table.concat({ + -- tabSizeClassName, + -- if disabled then styles.TabDisabled else styles.Tab, + -- if not disabled and currentTab == id then styles.TabCurrent else "", + -- }, " "), + key = id, + -- onKeyDown={handleKeyDown}, + onMouseDown = function() + -- deviation: check if disabled + if not disabled then + selectTab(id) + end + end, + -- + -- + -- {label} + text = label, + frameProps = { + LayoutOrder = index + startOrderAt, + }, + }) + + -- if title then + -- button = React.createElement(Tooltip, { + -- key = id, + -- -- className = tooltipStyles.Tooltip, + -- label = title, + -- }, button) + -- end + + return button + end) + ) +end +exports.TabBar = TabBar +exports.default = TabBar + +return exports diff --git a/packages/react-devtools-shared/src/devtools/views/context.luau b/packages/react-devtools-shared/src/devtools/views/context.luau new file mode 100644 index 00000000..b4977000 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/context.luau @@ -0,0 +1,49 @@ +--!strict +-- upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/devtools/views/context.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 +local LuauPolyfill = require(Packages.LuauPolyfill) +type Array = LuauPolyfill.Array +local exports = {} + +local React = require(Packages.React) +local createContext = React.createContext + +-- local Store = require(script.Parent.Parent.store) +local devtoolsTypes = require(script.Parent.Parent.types) +type Store = devtoolsTypes.Store + +-- deviation: importing DevTools would be a cyclic import, so we inline the +-- `ViewAttributeSource` type here +-- local DevTools = require(script.Parent.DevTools) +-- type ViewAttributeSource = DevTools.ViewAttributeSource +type ViewAttributeSource = (id: number, path: Array) -> () + +local bridgeModule = require(script.Parent.Parent.Parent.bridge) +type FrontendBridge = bridgeModule.FrontendBridge + +exports.BridgeContext = createContext((nil :: any) :: FrontendBridge) +exports.BridgeContext.displayName = "BridgeContext" + +exports.StoreContext = createContext((nil :: any) :: Store) +exports.StoreContext.displayName = "StoreContext" + +export type ContextMenuContextType = { + isEnabledForInspectedElement: boolean, + viewAttributeSourceFunction: ViewAttributeSource | nil, +} + +exports.ContextMenuContext = createContext({ + isEnabledForInspectedElement = false, + viewAttributeSourceFunction = nil, +}) +exports.ContextMenuContext.displayName = "ContextMenuContext" + +return exports diff --git a/packages/react-devtools-shared/src/devtools/views/hooks.luau b/packages/react-devtools-shared/src/devtools/views/hooks.luau new file mode 100644 index 00000000..99b7586e --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/hooks.luau @@ -0,0 +1,361 @@ +--!strict +-- upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/devtools/views/hooks.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 +local MorePolyfill = require(Packages.MorePolyfill) +local JSON = MorePolyfill.JSON +local LuauPolyfill = require(Packages.LuauPolyfill) +local Object = LuauPolyfill.Object +local console = LuauPolyfill.console +type Array = LuauPolyfill.Array +local exports = {} + +-- import throttle from 'lodash.throttle'; + +local React = require(Packages.React) +local useCallback = React.useCallback +local useEffect = React.useEffect +local useLayoutEffect = React.useLayoutEffect +local useReducer = React.useReducer +local useState = React.useState +local useContext = React.useContext + +local ReactRoblox = require(Packages.ReactRoblox) +local batchedUpdates = ReactRoblox.unstable_batchedUpdates + +local storageModule = require(script.Parent.Parent.Parent.storage) +local localStorageGetItem = storageModule.localStorageGetItem +local localStorageSetItem = storageModule.localStorageSetItem + +local contextModule = require(script.Parent.context) +local StoreContext = contextModule.StoreContext +local BridgeContext = contextModule.BridgeContext + +local utilsModule = require(script.Parent.Parent.utils) +local sanitizeForParse = utilsModule.sanitizeForParse +local smartParse = utilsModule.smartParse +local smartStringify = utilsModule.smartStringify + +type ACTION_RESET = { + type: "RESET", + externalValue: any, +} +type ACTION_UPDATE = { + type: "UPDATE", + editableValue: any, + externalValue: any, +} + +type UseEditableValueAction = ACTION_RESET | ACTION_UPDATE +type UseEditableValueDispatch = (action: UseEditableValueAction) -> () +type UseEditableValueState = { + editableValue: any, + externalValue: any, + hasPendingChanges: boolean, + isValid: boolean, + parsedValue: any, +} + +-- function useEditableValueReducer(state, action) { +-- switch (action.type) { +-- case 'RESET': +-- return { +-- ...state, +-- editableValue: smartStringify(action.externalValue), +-- externalValue: action.externalValue, +-- hasPendingChanges: false, +-- isValid: true, +-- parsedValue: action.externalValue, +-- }; +-- case 'UPDATE': +-- let isNewValueValid = false; +-- let newParsedValue; +-- try { +-- newParsedValue = smartParse(action.editableValue); +-- isNewValueValid = true; +-- } catch (error) {} +-- return { +-- ...state, +-- editableValue: sanitizeForParse(action.editableValue), +-- externalValue: action.externalValue, +-- hasPendingChanges: +-- smartStringify(action.externalValue) !== action.editableValue, +-- isValid: isNewValueValid, +-- parsedValue: isNewValueValid ? newParsedValue : state.parsedValue, +-- }; +-- default: +-- throw new Error(`Invalid action "${action.type}"`); +-- } +-- } + +-- Convenience hook for working with an editable value that is validated via JSON.parse. +-- export function useEditableValue( +-- externalValue: any, +-- ): [UseEditableValueState, UseEditableValueDispatch] { +-- const [state, dispatch] = useReducer< +-- UseEditableValueState, +-- UseEditableValueState, +-- UseEditableValueAction, +-- >(useEditableValueReducer, { +-- editableValue: smartStringify(externalValue), +-- externalValue, +-- hasPendingChanges: false, +-- isValid: true, +-- parsedValue: externalValue, +-- }); +-- if (!Object.is(state.externalValue, externalValue)) { +-- if (!state.hasPendingChanges) { +-- dispatch({ +-- type: 'RESET', +-- externalValue, +-- }); +-- } else { +-- dispatch({ +-- type: 'UPDATE', +-- editableValue: state.editableValue, +-- externalValue, +-- }); +-- } +-- } + +-- return [state, dispatch]; +-- } + +-- export function useIsOverflowing( +-- containerRef: {current: HTMLDivElement | null, ...}, +-- totalChildWidth: number, +-- ): boolean { +-- const [isOverflowing, setIsOverflowing] = useState(false); + +-- // It's important to use a layout effect, so that we avoid showing a flash of overflowed content. +-- useLayoutEffect(() => { +-- if (containerRef.current === null) { +-- return () => {}; +-- } + +-- const container = ((containerRef.current: any): HTMLDivElement); + +-- const handleResize = throttle( +-- () => setIsOverflowing(container.clientWidth <= totalChildWidth), +-- 100, +-- ); + +-- handleResize(); + +-- // It's important to listen to the ownerDocument.defaultView to support the browser extension. +-- // Here we use portals to render individual tabs (e.g. Profiler), +-- // and the root document might belong to a different window. +-- const ownerWindow = container.ownerDocument.defaultView; +-- ownerWindow.addEventListener('resize', handleResize); +-- return () => ownerWindow.removeEventListener('resize', handleResize); +-- }, [containerRef, totalChildWidth]); + +-- return isOverflowing; +-- } + +-- Forked from https://usehooks.com/useLocalStorage/ +local function useLocalStorage(key: string, initialValue: T | (() -> T)): (T, (value: T | (() -> T)) -> ()) + local getValueFromLocalStorage = useCallback(function() + local itemSuccess, item = pcall(function() + return localStorageGetItem(key) + end) + if not itemSuccess then + console.log(item) + end + + if item ~= nil then + local jsonSuccess, result = pcall(function() + return JSON.parse(item) + end) + if jsonSuccess then + return result + else + console.log(result) + end + end + + if type(initialValue) == "function" then + return ((initialValue :: any) :: () -> T)() + else + return initialValue + end + end, { initialValue :: any, key }) + + local storedValue, setStoredValue = useState(getValueFromLocalStorage :: () -> T) + + local setValue = useCallback(function(value) + xpcall(function() + local valueToStore = if type(value) == "function" then (value :: any)(storedValue) else value + setStoredValue(valueToStore) + localStorageSetItem(key, JSON.stringify(valueToStore)) + end, function(error) + console.log(error) + end) + end, { key :: any, storedValue }) + + -- Listen for changes to this local storage value made from other windows. + -- This enables the e.g. "⚛️ Elements" tab to update in response to changes from "⚛️ Settings". + -- useLayoutEffect(function() + -- local function onStorage(event) + -- local newValue = getValueFromLocalStorage() + -- if key == event.key and storedValue ~= newValue then + -- setValue(newValue) + -- end + -- end + + -- window.addEventListener("storage", onStorage) + + -- return function() + -- window.removeEventListener("storage", onStorage) + -- end + -- end, { getValueFromLocalStorage, key, storedValue, setValue }) + + return storedValue, setValue +end +exports.useLocalStorage = useLocalStorage + +local function useModalDismissSignal( + modalRef: { current: HTMLDivElement | nil }, + dismissCallback: () -> (), + dismissOnClickOutside: boolean? -- = true, +): () + if dismissOnClickOutside == nil then + dismissOnClickOutside = true + end + useEffect(function() + if modalRef.current == nil then + return function() end + end + + local function handleDocumentKeyDown(event: any) + local key = event.key + if key == "Escape" then + dismissCallback() + end + end + + local function handleDocumentClick(event: any) + -- $FlowFixMe + if modalRef.current ~= nil and not modalRef.current.contains(event.target) then + event.stopPropagation() + event.preventDefault() + + dismissCallback() + end + end + + -- It's important to listen to the ownerDocument to support the browser extension. + -- Here we use portals to render individual tabs (e.g. Profiler), + -- and the root document might belong to a different window. + local ownerDocument = modalRef.current.ownerDocument + ownerDocument.addEventListener("keydown", handleDocumentKeyDown) + if dismissOnClickOutside then + ownerDocument.addEventListener("click", handleDocumentClick) + end + + return function() + ownerDocument:removeEventListener("keydown", handleDocumentKeyDown) + ownerDocument:removeEventListener("click", handleDocumentClick) + end + end, { modalRef :: any, dismissCallback, dismissOnClickOutside }) +end +exports.useModalDismissSignal = useModalDismissSignal + +-- // Copied from https://github.com/facebook/react/pull/15022 +local function useSubscription(config: { + getCurrentValue: () -> Value, + subscribe: (callback: () -> ()) -> () -> (), +}): Value + local getCurrentValue = config.getCurrentValue + local subscribe = config.subscribe + + local state, setState = useState({ + getCurrentValue = getCurrentValue, + subscribe = subscribe, + value = getCurrentValue(), + }) + + if state.getCurrentValue ~= getCurrentValue or state.subscribe ~= subscribe then + setState({ + getCurrentValue = getCurrentValue, + subscribe = subscribe, + value = getCurrentValue(), + }) + end + + useEffect(function() + local didUnsubscribe = false + + local function checkForUpdates() + if didUnsubscribe then + return + end + + setState(function(prevState) + if prevState.getCurrentValue ~= getCurrentValue or prevState.subscribe ~= subscribe then + return prevState + end + + local value = getCurrentValue() + if prevState.value == value then + return prevState + end + + return Object.assign(table.clone(prevState), { value = value }) + end) + end + local unsubscribe = subscribe(checkForUpdates) + + checkForUpdates() + + return function() + didUnsubscribe = true + unsubscribe() + end + end, { getCurrentValue :: any, subscribe }) + + return state.value +end +exports.useSubscription = useSubscription + +-- export function useHighlightNativeElement() { +-- const bridge = useContext(BridgeContext); +-- const store = useContext(StoreContext); + +-- const highlightNativeElement = useCallback( +-- (id: number) => { +-- const element = store.getElementByID(id); +-- const rendererID = store.getRendererIDForElement(id); +-- if (element !== null && rendererID !== null) { +-- bridge.send('highlightNativeElement', { +-- displayName: element.displayName, +-- hideAfterTimeout: false, +-- id, +-- openNativeElementsPanel: false, +-- rendererID, +-- scrollIntoView: false, +-- }); +-- } +-- }, +-- [store, bridge], +-- ); + +-- const clearHighlightNativeElement = useCallback(() => { +-- bridge.send('clearNativeElementHighlight'); +-- }, [bridge]); + +-- return { +-- highlightNativeElement, +-- clearHighlightNativeElement, +-- }; +-- } + +return exports diff --git a/packages/react-devtools-shared/src/devtools/views/portaledContent.luau b/packages/react-devtools-shared/src/devtools/views/portaledContent.luau new file mode 100644 index 00000000..afbbe1c2 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/portaledContent.luau @@ -0,0 +1,62 @@ +--!strict +-- upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/devtools/views/portaledContent.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 +local MorePolyfill = require(Packages.MorePolyfill) +local JSON = MorePolyfill.JSON +local LuauPolyfill = require(Packages.LuauPolyfill) +local Object = LuauPolyfill.Object +local console = LuauPolyfill.console +type Array = LuauPolyfill.Array + +local exports = {} + +-- import throttle from 'lodash.throttle'; + +local React = require(Packages.React) +local useContext = React.useContext +local ReactRoblox = require(Packages.ReactRoblox) +local createPortal = ReactRoblox.createPortal +-- import ErrorBoundary from './ErrorBoundary'; +local contextModule = require(script.Parent.context) +local StoreContext = contextModule.StoreContext +local Store = require(script.Parent.Parent.store) +type Store = Store.Store + +export type Props = { portalContainer: Instance? } + +local function portaledContent

( + Component: React.StatelessFunctionalComponent

, + onErrorRetry: ((store: Store) -> ())? +): React.StatelessFunctionalComponent

+ local function PortaledContent(props: P & Props) + local portalContainer = props.portalContainer + local rest = table.clone(props) :: any + rest.portalContainer = nil + local store = useContext(StoreContext) + + -- deviation: todo uncomment error boundary + local children = + -- React.createElement( + -- ErrorBoundary, + -- { store = store, onRetry = onErrorRetry }, + React.createElement(Component, rest) + -- ) + + return if portalContainer ~= nil then createPortal(children, portalContainer) else children + end + + return PortaledContent +end +exports.default = portaledContent +exports.portaledContent = portaledContent + +return exports diff --git a/packages/react-devtools-shared/src/devtools/views/roblox/Div.luau b/packages/react-devtools-shared/src/devtools/views/roblox/Div.luau new file mode 100644 index 00000000..e1fd8eaf --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/roblox/Div.luau @@ -0,0 +1,104 @@ +--!strict + +--[[ + This file is meant to somehow facilate JS translation of div elements to Roblox. + It is not API compatible with a `div`. +]] + +local Packages = script.Parent.Parent.Parent.Parent.Parent + +local LuauPolyfill = require(Packages.LuauPolyfill) +local Object = LuauPolyfill.Object + +local React = require(Packages.React) +local ReactRoblox = require(Packages.ReactRoblox) + +type Props = { + name: string?, + padding: number | UDim | nil, + xAlignment: Enum.HorizontalAlignment?, + yAlignment: Enum.VerticalAlignment?, + direction: Enum.FillDirection | nil | false, + maxWidth: number?, + minWidth: number?, + maxHeight: number?, + minHeight: number?, + order: number?, + onClick: (() -> ())?, + frameProps: { + ClipsDescendants: boolean?, + LayoutOrder: number?, + Visible: boolean, + [string]: any, + }?, + layoutProps: { + HorizontalAlignment: Enum.HorizontalAlignment?, + VerticalAlignment: Enum.VerticalAlignment?, + }?, + children: { React.Node }?, +} + +local function Div(props: Props, ref) + local direction: Enum.FillDirection | false = if props.direction == nil + then Enum.FillDirection.Vertical + else props.direction + local horizontalAlignment = if props.xAlignment == nil then Enum.HorizontalAlignment.Left else props.xAlignment + local verticalAlignment = if props.yAlignment == nil + then if direction == Enum.FillDirection.Vertical + then Enum.VerticalAlignment.Top + else Enum.VerticalAlignment.Center + else props.yAlignment + local padding = if props.padding == nil + then UDim.new(0, 0) + elseif type(props.padding) == "number" then UDim.new(0, props.padding) + else props.padding + local onClick = props.onClick + + return React.createElement( + if onClick then "TextButton" else "Frame", + Object.assign( + { + ref = ref, + Name = props.name, + BackgroundTransparency = 1, + BorderSizePixel = 0, + Size = UDim2.new(1, 0, 1, 0), + LayoutOrder = props.order, + }, + if onClick + then { + AutoButtonColor = false, + Text = "", + [ReactRoblox.Event.Activated] = onClick, + } + else {}, + props.frameProps + ), + if direction + then React.createElement( + "UIListLayout", + Object.assign({ + Name = "list-layout-" .. string.lower(direction.Name), + Padding = padding, + SortOrder = Enum.SortOrder.LayoutOrder, + FillDirection = direction, + HorizontalAlignment = horizontalAlignment, + VerticalAlignment = verticalAlignment, + }, props.layoutProps) + ) + else nil, + if props.maxWidth + or props.minWidth + or props.maxHeight + or props.minHeight + then React.createElement("UISizeConstraint", { + Name = "size-constraint", + MaxSize = Vector2.new(props.maxWidth or math.huge, props.maxHeight or math.huge), + MinSize = Vector2.new(props.minWidth or 0, props.minHeight or 0), + }) + else nil, + props.children + ) +end + +return React.forwardRef(Div) diff --git a/packages/react-devtools-shared/src/devtools/views/roblox/Text.luau b/packages/react-devtools-shared/src/devtools/views/roblox/Text.luau new file mode 100644 index 00000000..db0f4d23 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/roblox/Text.luau @@ -0,0 +1,78 @@ +--!strict + +--[[ + This file is meant to somehow facilate JS translation of span elements to Roblox. + It is not API compatible with a `span`. +]] +local TextService = game:GetService("TextService") + +local Packages = script.Parent.Parent.Parent.Parent.Parent + +local LuauPolyfill = require(Packages.LuauPolyfill) +local Object = LuauPolyfill.Object + +local React = require(Packages.React) +local ReactRoblox = require(Packages.ReactRoblox) + +type Props = { + name: string?, + text: string, + fontSize: number?, + expand: boolean?, + onMouseDown: (() -> ())?, + disabled: boolean?, + order: number?, + frameProps: { + [string]: any, + }?, +} + +local function Text(props: Props, ref) + local isButton = props.onMouseDown ~= nil + local expand = if props.expand == nil then true else props.expand + + local text = props.text + + local font = Enum.Font.Arial + local fontSize = if props.fontSize == nil then 12 else props.fontSize + + local padding = 6 + + local textSize = React.useMemo(function() + return TextService:GetTextSize(text, fontSize, font, Vector2.new(math.huge, math.huge)) + + 2 * Vector2.new(padding, padding) + end, { text }) + + return React.createElement( + if isButton then "TextButton" else "TextLabel", + Object.assign({ + ref = ref, + Name = props.name, + AutoButtonColor = if isButton then not props.disabled else nil, + BackgroundTransparency = if isButton then 0.5 else 1, + BorderSizePixel = 0, + LayoutOrder = props.order, + Size = UDim2.new(0, 0, if expand then 1 else 0, 0), + TextSize = fontSize, + Font = font, + Text = text, + AutomaticSize = if expand then Enum.AutomaticSize.X else Enum.AutomaticSize.XY, + TextWrapped = true, + [ReactRoblox.Event.Activated] = if not props.disabled then props.onMouseDown else nil, + }, props.frameProps), + React.createElement("UIPadding", { + Name ="padding", + PaddingLeft = UDim.new(0, padding), + PaddingRight = UDim.new(0, padding), + PaddingTop = UDim.new(0, padding), + PaddingBottom = UDim.new(0, padding), + }), + React.createElement("UISizeConstraint", { + Name = "size-constraint", + MaxSize = Vector2.new(textSize.X, if expand then math.huge else textSize.Y), + MinSize = textSize, + }) + ) +end + +return React.forwardRef(Text) diff --git a/packages/react-devtools-shared/src/devtools/views/utils.luau b/packages/react-devtools-shared/src/devtools/views/utils.luau new file mode 100644 index 00000000..fe2e03c9 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/utils.luau @@ -0,0 +1,226 @@ +-- Bad regex. Make it not match anything. +-- TODO: maybe warn in console? +-- Bad regex. Make it not match anything. +-- TODO: maybe warn in console? +-- upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/devtools/views/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. +-- * +-- * @flow +-- */ + +local Packages = script.Parent.Parent.Parent.Parent +local LuauPolyfill = require(Packages.LuauPolyfill) +local Object = LuauPolyfill.Object +local Array = LuauPolyfill.Array +type Object = LuauPolyfill.Object + +-- local RegexLua = require(Packages.RegexLua) +-- type Regexp = RegexLua.Regexp + +-- deviation: `escapeStringRegExp` needs regex replace functionality for its implementation +-- but Regex-Lua has not implemented it yet. +-- import escapeStringRegExp from 'escape-string-regexp'; +local function escapeStringRegExp(value: string): string + return value +end + +local meta = require(script.Parent.Parent.Parent.hydration).meta +local formatDataForPreview = require(script.Parent.Parent.Parent.utils).formatDataForPreview +local ReactDebugHooksModule = require(Packages.ReactDebugTools) +type HooksTree = ReactDebugHooksModule.HooksTree + +local exports = {} + +local function alphaSortEntries( + -- deviation: Luau does not have tuple types + entryA: { string | mixed }, + entryB: { string | mixed } +): number + local a = entryA[0] :: string + local b = entryB[0] :: string + if "" .. tonumber(a) == a then + if "" .. tonumber(b) ~= b then + return -1 + end + return if tonumber(a) < tonumber(b) then -1 else 1 + end + return if a < b then -1 else 1 +end +exports.alphaSortEntries = alphaSortEntries + +local function createRegExp(string_: string): RegExp + -- Allow /regex/ syntax with optional last / + if string.sub(string_, 1, 1) == "/" then + -- Cut off first slash + string_ = string.sub(string_, 2) + -- Cut off last slash, but only if it's there + if string.sub(string_, -1, -1) == "/" then + string_ = string.sub(string_, 1, -1) + end + local success, result: any = pcall(function() + return RegExp(string_, "i") + end) + if success then + return result + else + -- Bad regex. Make it not match anything. + -- TODO: maybe warn in console? + return RegExp(".^") + end + end + + local function isLetter(char: string): boolean + return string.lower(char) ~= string.upper(char) + end + + local function matchAnyCase(char: string) + if not isLetter(char) then + -- Don't mess with special characters like [. + return char + end + return "[" .. string.lower(char) .. string.upper(char) .. "]" + end + + -- 'item' should match 'Item' and 'ListItem', but not 'InviteMom'. + -- To do this, we'll slice off 'tem' and check first letter separately. + local escaped = escapeStringRegExp(string_) + local firstChar = string.sub(escaped, 1, 1) + local restRegex = "" + -- For 'item' input, restRegex becomes '[tT][eE][mM]' + -- We can't simply make it case-insensitive because first letter case matters. + for i = 1, #escaped do + restRegex ..= matchAnyCase(string.sub(escaped, i, i)) + end + + if not isLetter(firstChar) then + -- We can't put a non-character like [ in a group + -- so we fall back to the simple case. + return RegExp(firstChar + restRegex) + end + + -- Construct a smarter regex. + return RegExp( + -- For example: + -- (^[iI]|I)[tT][eE][mM] + -- Matches: + -- 'Item' + -- 'ListItem' + -- but not 'InviteMom' + "(^" + .. matchAnyCase(firstChar) + .. "|" + .. string.upper(firstChar) + .. ")" + .. restRegex + ) +end +exports.createRegExp = createRegExp + +local function getMetaValueLabel(data: Object): string | nil + if hasOwnProperty.call(data, meta.preview_long) then + return data[meta.preview_long] + else + return formatDataForPreview(data, true) + end +end +exports.getMetaValueLabel = getMetaValueLabel + +local function sanitize(data: Object): () + for key, value in data do + if value and value[meta.type] then + data[key] = getMetaValueLabel(value) + elseif value ~= nil then + if Array.isArray(value) then + sanitize(value) + elseif type(value) == "table" then + sanitize(value) + end + end + end +end + +local function serializeDataForCopy(props: Object): string + local cloned = Object.assign({}, props) + + sanitize(cloned) + + local success, result: any = pcall(function() + return JSON.stringify(cloned, nil, 2) + end) + if success then + return result + else + return "" + end +end +exports.serializeDataForCopy = serializeDataForCopy + +local function serializeHooksForCopy(hooks: HooksTree | nil): string + -- $FlowFixMe "HooksTree is not an object" + local cloned = Object.assign({}, hooks) + + local queue = table.clone(cloned) + + while #queue > 0 do + local current = table.remove(queue) + + -- These aren't meaningful + current.id = nil + current.isStateEditable = nil + + if #current.subHooks > 0 then + for _, subHook in current.subHooks do + table.insert(queue, subHook) + end + end + end + + sanitize(cloned) + + local success, result: any = pcall(function() + return JSON.stringify(cloned, nil, 2) + end) + if success then + return result + else + return "" + end +end +exports.serializeHooksForCopy = serializeHooksForCopy + +-- Keeping this in memory seems to be enough to enable the browser to download larger profiles. +-- Without this, we would see a "Download failed: network error" failure. +local downloadUrl = nil + +local function downloadFile(element: HTMLAnchorElement, filename: string, text: string): () + local blob = Blob.new({ text }, { type = "text/plain;charset=utf-8" }) + + if downloadUrl ~= nil then + URL.revokeObjectURL(downloadUrl) + end + + downloadUrl = URL.createObjectURL(blob) + + element:setAttribute("href", downloadUrl) + element:setAttribute("download", filename) + + element:click() +end +exports.downloadFile = downloadFile + +local function truncateText(text: string, maxLength: number): string + local length = string.len(text) + if length > maxLength then + error("todo: not implemented") + -- return LuauPolyfill.String. (text.substr(0, Math.floor(maxLength / 2)) + "…" + text.substr(length - Math.ceil(maxLength / 2) - 1)) + else + return text + end +end +exports.truncateText = truncateText + +return exports diff --git a/packages/react-devtools-shared/src/init.luau b/packages/react-devtools-shared/src/init.luau index dab8d8a6..90d32834 100644 --- a/packages/react-devtools-shared/src/init.luau +++ b/packages/react-devtools-shared/src/init.luau @@ -1,8 +1,20 @@ +local bridge = require(script.bridge) + +export type FrontendBridge = bridge.FrontendBridge + +local backend = require(script.backend) + +local devtools = require(script.devtools) +export type Store = devtools.Store + +local hydration = require(script.hydration) +local hook = require(script.hook) + return { - backend = require(script.backend), - bridge = require(script.bridge), - devtools = require(script.devtools), - hydration = require(script.hydration), - hook = require(script.hook), + backend = backend, + bridge = bridge, + devtools = devtools, + hydration = hydration, + hook = hook, utils = require(script.utils), } diff --git a/packages/react-devtools-shared/wally.toml b/packages/react-devtools-shared/wally.toml index 6f9b434e..814c8c33 100644 --- a/packages/react-devtools-shared/wally.toml +++ b/packages/react-devtools-shared/wally.toml @@ -9,6 +9,8 @@ realm = 'shared' [dependencies] LuauPolyfill = 'jsdotlua/luau-polyfill@1.2.3' +MorePolyfill = 'jsdotlua/more-polyfill@0.1.0' +RegexLua = 'jsdotlua/regex-lua@1.0.2' React = 'jsdotlua/react@17.0.2' ReactDebugTools = 'jsdotlua/react-debug-tools@17.0.2' ReactIs = 'jsdotlua/react-is@17.0.2' diff --git a/packages/react-devtools/default.project.json b/packages/react-devtools/default.project.json new file mode 100644 index 00000000..6ce4e159 --- /dev/null +++ b/packages/react-devtools/default.project.json @@ -0,0 +1,6 @@ +{ + "name": "react-devtools-core", + "tree": { + "$path": "src/" + } +} \ No newline at end of file diff --git a/packages/react-devtools/src/init.luau b/packages/react-devtools/src/init.luau new file mode 100644 index 00000000..7b46bccb --- /dev/null +++ b/packages/react-devtools/src/init.luau @@ -0,0 +1,34 @@ +--!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 + +local connectToDevTools = require(Packages.ReactDevtoolsCore).backend.connectToDevTools + +-- Connect immediately with default options. +-- If you need more control, use `react-devtools-core` directly instead of `react-devtools`. +connectToDevTools() + +-- deviation: we need to inject devtools again +local ReactReconciler = require(Packages.ReactReconciler)({}) + +local function mockFindFiberByHostInstance() + warn("mockFindFiberByHostInstance") + return nil +end + +ReactReconciler.injectIntoDevTools({ + findFiberByHostInstance = mockFindFiberByHostInstance, + bundleType = if _G.__DEV__ then 1 else 0, + version = "17.0.2", + rendererPackageName = "ReactRoblox", +}) + +return nil diff --git a/packages/react-devtools/wally.toml b/packages/react-devtools/wally.toml new file mode 100644 index 00000000..ad189530 --- /dev/null +++ b/packages/react-devtools/wally.toml @@ -0,0 +1,11 @@ +[package] +name = 'jsdotlua/react-devtools' +description = 'https://github.com/grilme99/CorePackages' +version = '17.0.2' +license = 'MIT' +authors = ['Facebook, Inc'] +registry = 'https://github.com/UpliftGames/wally-index' +realm = 'shared' + +[dependencies] +ReactDevtoolsCore = 'jsdotlua/react-devtools-core@17.0.2' diff --git a/packages/react-reconciler/src/ReactFiberHostContext.new.luau b/packages/react-reconciler/src/ReactFiberHostContext.new.luau index 64f56a10..c16c5431 100644 --- a/packages/react-reconciler/src/ReactFiberHostContext.new.luau +++ b/packages/react-reconciler/src/ReactFiberHostContext.new.luau @@ -38,7 +38,7 @@ local contextStackCursor: StackCursor = createCursor(N local contextFiberStackCursor: StackCursor = createCursor(NO_CONTEXT) local rootInstanceStackCursor: StackCursor = createCursor(NO_CONTEXT) -function requiredContext(c: Value | NoContextT): Value +local function requiredContext(c: Value | NoContextT): Value -- performance: eliminate expensive optional cmp in hot path -- invariant( -- c ~= NO_CONTEXT, @@ -48,14 +48,14 @@ function requiredContext(c: Value | NoContextT): Value return c :: any end -function getRootHostContainer(): Container +local function getRootHostContainer(): Container -- performance: inline requiredContext impl for hot path -- local rootInstance = requiredContext(rootInstanceStackCursor.current) -- return rootInstance return rootInstanceStackCursor.current end -function pushHostContainer(fiber: Fiber, nextRootInstance: Container) +local function pushHostContainer(fiber: Fiber, nextRootInstance: Container) -- Push current root instance onto the stack -- This allows us to reset root when portals are popped. push(rootInstanceStackCursor, nextRootInstance, fiber) @@ -75,20 +75,20 @@ function pushHostContainer(fiber: Fiber, nextRootInstance: Container) push(contextStackCursor, nextRootContext, fiber) end -function popHostContainer(fiber: Fiber) +local function popHostContainer(fiber: Fiber) pop(contextStackCursor, fiber) pop(contextFiberStackCursor, fiber) pop(rootInstanceStackCursor, fiber) end -function getHostContext(): HostContext +local function getHostContext(): HostContext -- performance: inline requiredContext impl for hot path -- local context = requiredContext(contextStackCursor.current) -- return context return contextStackCursor.current end -function pushHostContext(fiber: Fiber) +local function pushHostContext(fiber: Fiber) local rootInstance: Container = requiredContext(rootInstanceStackCursor.current) local context: HostContext = requiredContext(contextStackCursor.current) local nextContext = getChildHostContext(context, fiber.type, rootInstance) @@ -104,7 +104,7 @@ function pushHostContext(fiber: Fiber) push(contextStackCursor, nextContext, fiber) end -function popHostContext(fiber: Fiber) +local function popHostContext(fiber: Fiber) -- Do not pop unless this Fiber provided the current context. -- pushHostContext() only pushes Fibers that provide unique contexts. if contextFiberStackCursor.current ~= fiber then diff --git a/packages/react-reconciler/src/ReactFiberReconciler.new.luau b/packages/react-reconciler/src/ReactFiberReconciler.new.luau index 0220629a..6d334134 100644 --- a/packages/react-reconciler/src/ReactFiberReconciler.new.luau +++ b/packages/react-reconciler/src/ReactFiberReconciler.new.luau @@ -736,7 +736,7 @@ if __DEV__ then end end -function findHostInstanceByFiber(fiber: Fiber): Instance | TextInstance | nil +local function findHostInstanceByFiber(fiber: Fiber): Instance | TextInstance | nil local hostFiber = findCurrentHostFiber(fiber) if hostFiber == nil then return nil @@ -744,15 +744,16 @@ function findHostInstanceByFiber(fiber: Fiber): Instance | TextInstance | nil return hostFiber.stateNode end -function emptyFindFiberByHostInstance(instance: Instance | TextInstance): Fiber | nil +local function emptyFindFiberByHostInstance(instance: Instance | TextInstance): Fiber | nil return nil end -function getCurrentFiberForDevTools() +local function getCurrentFiberForDevTools() return ReactCurrentFiber.current end exports.injectIntoDevTools = function(devToolsConfig: DevToolsConfig): boolean + -- print("injectIntoDevTools", devToolsConfig) local findFiberByHostInstance = devToolsConfig.findFiberByHostInstance local ReactCurrentDispatcher = ReactSharedInternals.ReactCurrentDispatcher local getCurrentFiber = nil diff --git a/packages/roblox-devtools-plugin/default.project.json b/packages/roblox-devtools-plugin/default.project.json new file mode 100644 index 00000000..bb873846 --- /dev/null +++ b/packages/roblox-devtools-plugin/default.project.json @@ -0,0 +1,55 @@ +{ + "name": "react-lua-roblox-devtools-plugin", + "tree": { + "$path": "src/", + "Packages": { + "$className": "Folder", + "React": { + "$path": "../react/default.project.json" + }, + "ReactDevtools": { + "$path": "../react-devtools/default.project.json" + }, + "ReactDevtoolsCore": { + "$path": "../react-devtools-core/default.project.json" + }, + "ReactDebugTools": { + "$path": "../react-debug-tools/default.project.json" + }, + "ReactDevtoolsShared": { + "$path": "../react-devtools-shared/default.project.json" + }, + "ReactIs": { + "$path": "../react-is/default.project.json" + }, + "ReactReconciler": { + "$path": "../react-reconciler/default.project.json" + }, + "ReactRoblox": { + "$path": "../react-roblox/default.project.json" + }, + "RoactCompat": { + "$path": "../roact-compat/default.project.json" + }, + "Scheduler": { + "$path": "../scheduler/default.project.json" + }, + "Shared": { + "$path": "../shared/default.project.json" + }, + "MorePolyfill": { + "$path": "../more-polyfill/default.project.json" + }, + + "_Index": { + "$path": "../../deps/_Index" + }, + "LuauPolyfill": { + "$path": "../../deps/LuauPolyfill.lua" + }, + "Promise": { + "$path": "../../deps/Promise.lua" + } + } + } +} diff --git a/packages/roblox-devtools-plugin/src/RobloxDevtools.luau b/packages/roblox-devtools-plugin/src/RobloxDevtools.luau new file mode 100644 index 00000000..87a58c2d --- /dev/null +++ b/packages/roblox-devtools-plugin/src/RobloxDevtools.luau @@ -0,0 +1,49 @@ +local Packages = script.Parent.Packages + +local React = require(Packages.React) +local ReactDevtoolsShared = require(Packages.ReactDevtoolsShared) +local DevTools = ReactDevtoolsShared.devtools.devtools.Components.views.DevTools.DevTools + +local setupDevtools = require(script.Parent.setupDevtools) + +local function RobloxDevtools(_: {}) + local data, setData = React.useState(nil) + + React.useEffect(function() + if not data then + local devtoolsSetupData = setupDevtools() + + setData({ + store = devtoolsSetupData.store, + bridge = devtoolsSetupData.bridge, + }) + end + return + end, { data or false }) + + return React.createElement( + "Frame", + { + BackgroundColor3 = Color3.fromRGB(235, 235, 235), + BorderSizePixel = 0, + Size = UDim2.fromScale(1, 1), + }, + if data + then React.createElement(DevTools, { + bridge = data.bridge, --((bridge:: any):: FrontendBridge), + -- canViewElementSourceFunction = canViewElementSourceFunction, + showTabBar = true, + store = data.store, --((store:: any):: Store), + warnIfLegacyBackendDetected = true, + -- viewElementSourceFunction = viewElementSourceFunction, + }) + else React.createElement("TextLabel", { + BackgroundTransparency = 1, + BorderSizePixel = 0, + Size = UDim2.new(1, 0, 1, 0), + Text = "Devtools not loaded", + }) + ) +end + +return RobloxDevtools diff --git a/packages/roblox-devtools-plugin/src/createPluginToolbar.luau b/packages/roblox-devtools-plugin/src/createPluginToolbar.luau new file mode 100644 index 00000000..3ff40c82 --- /dev/null +++ b/packages/roblox-devtools-plugin/src/createPluginToolbar.luau @@ -0,0 +1,61 @@ +local Packages = script.Parent.Packages + +local React = require(Packages.React) +local ReactRoblox = require(Packages.ReactRoblox) + +local RobloxDevtools = require(script.Parent.RobloxDevtools) +local teardown = require(script.Parent.teardown) +type Teardown = Teardown.Teardown + +local function createPluginToolbar(plugin: Plugin): Teardown + local pluginToolbar = plugin:CreateToolbar("React Devtools Extension") + local openDevtoolsButton = pluginToolbar:CreateButton("Open", "", "") + + local pluginGui = plugin:CreateDockWidgetPluginGui( + "react-dev-tools", + DockWidgetPluginGuiInfo.new(Enum.InitialDockState.Right, true, false, 400, 300, 200, 300) + ) + pluginGui.ZIndexBehavior = Enum.ZIndexBehavior.Sibling + pluginGui.Title = "React Devtools Extension" + pluginGui.Name = pluginGui.Title + + local root = nil + + local function updateDevtoolsWindow(enabled: boolean) + if not enabled and root ~= nil then + root:unmount() + root = nil + return + end + if enabled and root == nil then + local container = Instance.new("Folder") + container.Name = "View" + container.Parent = pluginGui + local element = React.createElement(RobloxDevtools) + + root = ReactRoblox.createRoot(container) + + root:render(element, container) + end + end + + local function toggleDevtoolsWindow() + pluginGui.Enabled = not pluginGui.Enabled + + updateDevtoolsWindow(pluginGui.Enabled) + end + + updateDevtoolsWindow(pluginGui.Enabled) + + local openDevtoolsConnection = openDevtoolsButton.Click:Connect(toggleDevtoolsWindow) + + return function() + if root then + root:unmount() + root = nil + end + teardown.teardown(openDevtoolsConnection, pluginGui) + end +end + +return createPluginToolbar diff --git a/packages/roblox-devtools-plugin/src/main.server.lua b/packages/roblox-devtools-plugin/src/main.server.lua new file mode 100644 index 00000000..ca129db0 --- /dev/null +++ b/packages/roblox-devtools-plugin/src/main.server.lua @@ -0,0 +1,31 @@ +_G.__DEV__ = true +-- _G.__DEBUG__ = true + +local RunService = game:GetService("RunService") + +local runModes = { + run = RunService:IsRunMode(), + studio = RunService:IsStudio(), + server = RunService:IsServer(), + client = RunService:IsClient(), + edit = RunService:IsEdit(), +} + +if runModes.run then + return +end + +if runModes.edit then + return +end + +-- print("\nrunModes", runModes) + +local createPluginToolbar = require(script.Parent.createPluginToolbar) +local teardown = require(script.Parent.teardown).teardown + +local teardownPlugin = createPluginToolbar(plugin) + +plugin.Unloading:Connect(function() + teardown(teardownPlugin) +end) diff --git a/packages/roblox-devtools-plugin/src/setupDevtools.lua b/packages/roblox-devtools-plugin/src/setupDevtools.lua new file mode 100644 index 00000000..4010b135 --- /dev/null +++ b/packages/roblox-devtools-plugin/src/setupDevtools.lua @@ -0,0 +1,125 @@ +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Packages = script.Parent.Packages + +local LuauPolyfill = require(Packages.LuauPolyfill) +local Array = LuauPolyfill.Array +type Array = LuauPolyfill.Array + +local React = require(Packages.React) +local ReactDevtoolsShared = require(Packages.ReactDevtoolsShared) + +local installHook = ReactDevtoolsShared.hook.installHook +-- local initBackend = ReactDevtoolsShared.backend.initBackend +-- local Agent = ReactDevtoolsShared.backend.agent +local Bridge = ReactDevtoolsShared.bridge +local Store = ReactDevtoolsShared.devtools.store +type Store = ReactDevtoolsShared.Store + +installHook(_G) + +local frontendBindable: BindableEvent = ReplicatedStorage:FindFirstChild("ReactDevtoolsFrontendBindable") +local backendBindable: BindableEvent = ReplicatedStorage:FindFirstChild("ReactDevtoolsBackendBindable") + +local function setupDevtools(): { bridge: any, store: Store }? + local hook: DevToolsHook? = _G.__REACT_DEVTOOLS_GLOBAL_HOOK__ + + if hook == nil then + return nil + end + + if (frontendBindable :: BindableEvent?) == nil then + frontendBindable = Instance.new("BindableEvent") + frontendBindable.Name = "ReactDevtoolsFrontendBindable" + frontendBindable.Parent = ReplicatedStorage + end + + if (backendBindable :: BindableEvent?) == nil then + backendBindable = Instance.new("BindableEvent") + backendBindable.Name = "ReactDevtoolsBackendBindable" + backendBindable.Parent = ReplicatedStorage + end + + local listeners: { (any) -> () } = {} + + -- socket.onmessage + frontendBindable.Event:Connect(function(event) + -- print("[plugin] received event", event) + local data = event + -- local data = event.data + -- try { + -- if (typeof event.data === 'string') { + -- data = JSON.parse(event.data); + + -- if (__DEBUG__) { + -- debug('WebSocket.onmessage', data); + -- } + -- } else { + -- throw Error(); + -- } + -- } catch (e) { + -- log.error('Failed to parse JSON', event.data); + -- return; + -- } + + for _, fn in listeners do + local success, err: any = pcall(fn, data) + if not success then + -- log.error('Error calling listener', data); + warn("Error calling listener: " .. tostring(err)) + end + end + end) + + local bridge = Bridge.new({ + listen = function(fn) + -- print("[plugin] add listener") + table.insert(listeners, fn) + return function() + local index = Array.indexOf(listeners, fn) + if index >= 1 then + Array.splice(listeners, index, 2) + end + end + end, + send = function(event: string, payload: any, transferable: Array?) + -- send to backend! + -- socket.send(JSON.stringify({event, payload})); + if (backendBindable :: BindableEvent?) == nil then + warn("skip sending to backend", event, payload) + else + -- print("[plugin] send", event, payload) + backendBindable:Fire({ event = event, payload = payload }) + end + end, + }) + + -- print("create plugin bridge") + frontendBindable:SetAttribute("Ready", true) + + local store = Store.new(bridge, { + supportsNativeInspection = false, + -- supportsProfiling = true, + }) + + -- print("\n>>> Fire frontend bindable begin signal\n") + + -- log("Connected") + -- reload() + + -- local agent = Agent.new(bridge) + -- agent:addListener("shutdown", function() + -- -- If we received 'shutdown' from `agent`, we assume the `bridge` is already shutting down, + -- -- and that caused the 'shutdown' event on the `agent`, so we don't need to call `bridge.shutdown()` here. + -- hook.emit("shutdown") + -- end) + + -- initBackend(hook, agent, _G) + + return { + bridge = bridge, + store = store, + } +end + +return setupDevtools diff --git a/packages/roblox-devtools-plugin/src/teardown.lua b/packages/roblox-devtools-plugin/src/teardown.lua new file mode 100644 index 00000000..585567b4 --- /dev/null +++ b/packages/roblox-devtools-plugin/src/teardown.lua @@ -0,0 +1,37 @@ +export type Teardown = (() -> ()) | Instance | RBXScriptConnection | { Teardown } | nil + +local function teardown(...: Teardown) + for i = 1, select("#", ...) do + local element = select(i, ...) + local elementType = type(element) + + if element == nil then + -- nothing to do! + elseif elementType == "function" then + element() + elseif elementType == "table" then + for _, subElement in element do + teardown(subElement) + end + elseif elementType == "userdata" and typeof(element) == "RBXScriptConnection" then + element:Disconnect() + elseif elementType == "userdata" and typeof(element) == "Instance" then + element:Destroy() + else + warn("unable to teardown value of type `" .. elementType .. "`") + end + end +end + +local function join(...: Teardown): Teardown + local packed = table.pack(...) + local function teardownAll() + teardown(table.unpack(packed, 1, packed.n)) + end + return teardownAll +end + +return { + teardown = teardown, + join = join, +} diff --git a/packages/roblox-devtools-plugin/wally.toml b/packages/roblox-devtools-plugin/wally.toml new file mode 100644 index 00000000..1fca8bc2 --- /dev/null +++ b/packages/roblox-devtools-plugin/wally.toml @@ -0,0 +1,18 @@ +[package] +name = 'jsdotlua/roblox-devtools-plugin' +description = 'https://github.com/grilme99/CorePackages' +version = '17.0.2' +license = 'MIT' +authors = ['jeparlefrancais '] +registry = 'https://github.com/UpliftGames/wally-index' +realm = 'shared' + +[dependencies] +LuauPolyfill = 'jsdotlua/luau-polyfill@1.2.3' +React = 'jsdotlua/react@17.0.2' +ReactDebugTools = 'jsdotlua/react-debug-tools@17.0.2' +ReactDevtoolsShared = 'jsdotlua/react-devtools-shared@17.0.2' +# ReactIs = 'jsdotlua/react-is@17.0.2' +# ReactReconciler = 'jsdotlua/react-reconciler@17.0.2' +ReactRoblox = 'jsdotlua/react-roblox@17.0.2' +# Shared = 'jsdotlua/shared@17.0.2'