Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for internal Roblox module loading #24

Draft
wants to merge 31 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
23b8962
Add loadmodule support
vocksel Nov 6, 2024
edd1345
Fix building
vocksel Dec 16, 2024
55c2ed9
Use `test` for jest tests
vocksel Dec 16, 2024
f9205fb
Remove _getSource test since it doesn't provide much value
vocksel Dec 16, 2024
3c80d90
Remove loadmodule support
vocksel Dec 16, 2024
9e4123b
Cleanup the loadstring stacktrace
vocksel Dec 17, 2024
9cdfec9
Fix ancestry changes not clearing modules
vocksel Dec 17, 2024
3577403
Fix build error
vocksel Dec 17, 2024
ea9d979
Reference JestRuntime for module loading
vocksel Dec 17, 2024
60c6c94
Remove unused variable
vocksel Dec 18, 2024
798cf1e
Remove _loadModule, rename variable
vocksel Dec 18, 2024
780568e
Run tests with both loadmodule and loadstring
vocksel Dec 20, 2024
3ad5ea0
Start working through test errors
vocksel Dec 21, 2024
5492cdd
Track and run cleanup functions from loadmodule
vocksel Dec 22, 2024
b4a1189
Rename `module` variable to not step on the Lua global
vocksel Dec 22, 2024
ea58762
Track module dependencies
vocksel Dec 22, 2024
5df4cc2
Handle module consumers
vocksel Dec 23, 2024
c16954b
Fix up some more tests
vocksel Dec 23, 2024
ce88602
Merge remote-tracking branch 'origin/main' into roblox-internal
vocksel Dec 24, 2024
3c1d307
Don't run with loadmodule in CI
vocksel Dec 24, 2024
7851164
Fix consumer recursion
vocksel Dec 24, 2024
2899708
Fix analysis errors
vocksel Dec 24, 2024
ac453fb
Remove unused test helper
vocksel Dec 24, 2024
862b652
Merge remote-tracking branch 'origin/main' into roblox-internal
vocksel Dec 26, 2024
e920c49
Merge remote-tracking branch 'origin/main' into roblox-internal
vocksel Dec 26, 2024
a0c5185
Guve getModuleSource its own file
vocksel Dec 26, 2024
002f47f
Minor reverts
vocksel Dec 26, 2024
c65004d
Move some types around
vocksel Dec 26, 2024
6f52468
Add upstream link
vocksel Dec 26, 2024
ffeb485
Make sure to delete the old build dir before testing
vocksel Dec 29, 2024
e338481
Take a functional approach to ModuleLoader
vocksel Dec 29, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .lune/test-cloud.luau
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ local apiKey = assert(args.apiKey, "--apiKey must be supplied with a valid Open

local testPlaceFile = "test-place.rbxl"

run("rm", { "-rf", project.BUILD_PATH })
compile("dev")
run("rojo", { "build", project.ROJO_TESTS_PROJECT, "-o", testPlaceFile })

Expand Down
252 changes: 16 additions & 236 deletions src/ModuleLoader.luau
Original file line number Diff line number Diff line change
@@ -1,61 +1,25 @@
local Janitor = require("@pkg/Janitor")
local createModuleLoader = require("./createModuleLoader")
local types = require("./types")

local bind = require("./bind")
local createTablePassthrough = require("./createTablePassthrough")
local getCallerPath = require("./getCallerPath")
local getEnv = require("./getEnv")
local getRobloxTsRuntime = require("./getRobloxTsRuntime")

type CachedModuleResult = any

type ModuleConsumers = {
[string]: boolean,
}

-- Each module gets its own global table that it can modify via _G. This makes
-- it easy to clear out a module and the globals it defines without impacting
-- other modules. A module's function environment has all globals merged
-- together on _G
type ModuleGlobals = {
[any]: any,
}

type CachedModule = {
instance: ModuleScript,
isLoaded: boolean,
result: CachedModuleResult,
consumers: ModuleConsumers,
globals: ModuleGlobals,
}
type ModuleLoader = types.ModuleLoader

type ModuleLoaderProps = {
_cache: { [string]: CachedModule },
_loadstring: typeof(loadstring),
_debugInfo: typeof(debug.info),
_janitors: { [string]: any },
_globals: { [any]: any },

_loadedModuleChangedBindable: BindableEvent,
_loader: ModuleLoader,
loadedModuleChanged: RBXScriptSignal,
}

type ModuleLoaderImpl = {
__index: ModuleLoaderImpl,

new: () -> ModuleLoader,

require: (self: ModuleLoader, moduleScript: ModuleScript) -> any,
cache: (self: ModuleLoader, moduleScript: ModuleScript, result: any) -> (),
clearModule: (self: ModuleLoader, moduleScript: ModuleScript) -> (),
clear: (self: ModuleLoader) -> (),
new: () -> ModuleLoaderClass,

_loadCachedModule: (self: ModuleLoader, moduleScript: ModuleScript) -> CachedModuleResult,
_getSource: (self: ModuleLoader, moduleScript: ModuleScript) -> string,
_trackChanges: (self: ModuleLoader, moduleScript: ModuleScript) -> (),
_getConsumers: (self: ModuleLoader, moduleScript: ModuleScript) -> { ModuleScript },
require: (self: ModuleLoaderClass, moduleScript: ModuleScript) -> any,
cache: (self: ModuleLoaderClass, moduleScript: ModuleScript, result: any) -> (),
clearModule: (self: ModuleLoaderClass, moduleScript: ModuleScript) -> (),
clear: (self: ModuleLoaderClass) -> (),
}

export type ModuleLoader = typeof(setmetatable({} :: ModuleLoaderProps, {} :: ModuleLoaderImpl))
export type ModuleLoaderClass = typeof(setmetatable({} :: ModuleLoaderProps, {} :: ModuleLoaderImpl))

--[=[
ModuleScript loader that bypasses Roblox's require cache.
Expand All @@ -77,12 +41,7 @@ ModuleLoader.__index = ModuleLoader
function ModuleLoader.new()
local self = {}

self._cache = {}
self._loadstring = loadstring
self._debugInfo = debug.info
self._janitors = {}
self._globals = {}
self._loadedModuleChangedBindable = Instance.new("BindableEvent")
self._loader = createModuleLoader()

--[=[
Fired when any ModuleScript required through this class has its ancestry
Expand All @@ -105,70 +64,11 @@ function ModuleLoader.new()
@prop loadedModuleChanged RBXScriptSignal
@within ModuleLoader
]=]
self.loadedModuleChanged = self._loadedModuleChangedBindable.Event
self.loadedModuleChanged = self._loader.loadedModuleChanged

return setmetatable(self, ModuleLoader)
end

function ModuleLoader:_loadCachedModule(moduleScript: ModuleScript)
local cachedModule: CachedModule = self._cache[moduleScript:GetFullName()]

assert(
cachedModule.isLoaded,
"Requested module experienced an error while loading MODULE: "
.. moduleScript:GetFullName()
.. " - RESULT: "
.. tostring(cachedModule.result)
)

return cachedModule.result
end

--[=[
Gets the Source of a ModuleScript.

This method exists primarily so we can better write unit tests. Attempting
to index the Source property from a regular script context throws an error,
so this method allows us to safely fallback in tests.

@private
]=]
function ModuleLoader:_getSource(moduleScript: ModuleScript): string
local success, result = pcall(function()
return moduleScript.Source
end)

return if success then result else ""
end

--[=[
Tracks the changes to a required module's ancestry and `Source`.

When ancestry or `Source` changes, the `loadedModuleChanged` event is fired.
When this happens, the user should clear the cache and require the root
module again to reload.

@private
]=]
function ModuleLoader:_trackChanges(moduleScript: ModuleScript)
local existingJanitor = self._janitors[moduleScript:GetFullName()]
local janitor = if existingJanitor then existingJanitor else Janitor.new()

janitor:Cleanup()

janitor:Add(moduleScript.AncestryChanged:Connect(function()
self:clearModule(moduleScript)
end))

janitor:Add(moduleScript.Changed:Connect(function(prop: string)
if prop == "Source" then
self:clearModule(moduleScript)
end
end))

self._janitors[moduleScript:GetFullName()] = janitor
end

--[=[
Set the cached value for a module before it is loaded.

Expand All @@ -185,15 +85,7 @@ end
```
]=]
function ModuleLoader:cache(moduleScript: ModuleScript, result: any)
local cachedModule: CachedModule = {
instance = moduleScript,
result = result,
isLoaded = true,
consumers = {},
globals = createTablePassthrough(self._globals),
}

self._cache[moduleScript:GetFullName()] = cachedModule
self._loader.cache(moduleScript, result)
end

--[=[
Expand All @@ -209,117 +101,11 @@ end
```
]=]
function ModuleLoader:require(moduleScript: ModuleScript)
local cachedModule = self._cache[moduleScript:GetFullName()]
local callerPath = getCallerPath()

if not callerPath then
return nil
end

if cachedModule then
cachedModule.consumers[callerPath] = true
return self:_loadCachedModule(moduleScript)
end

local source = self:_getSource(moduleScript)
local moduleFn, parseError = self._loadstring(source, moduleScript:GetFullName())

if not moduleFn then
local message = if parseError then parseError else "<no error message>"
error(`Could not parse {moduleScript:GetFullName()}: {message}`)
end

local globals = createTablePassthrough(self._globals)

local newCachedModule: CachedModule = {
instance = moduleScript,
result = nil,
isLoaded = false,
consumers = {
[callerPath] = true,
},
globals = globals,
}
self._cache[moduleScript:GetFullName()] = newCachedModule

local env: any = getEnv(moduleScript, globals)
env.require = bind(self, self.require)
setfenv(moduleFn, env)

local success, result = xpcall(moduleFn, debug.traceback)

if success then
newCachedModule.isLoaded = true
newCachedModule.result = result
else
error(("Error requiring %s: %s"):format(moduleScript.Name, result))
end

self:_trackChanges(moduleScript)

return self:_loadCachedModule(moduleScript)
end

function ModuleLoader:_getConsumers(moduleScript: ModuleScript): { ModuleScript }
local function getConsumersRecursively(cachedModule: CachedModule, found: { [ModuleScript]: true })
for consumer in cachedModule.consumers do
local cachedConsumer = self._cache[consumer]

if cachedConsumer then
if not found[cachedConsumer.instance] then
found[cachedConsumer.instance] = true
getConsumersRecursively(cachedConsumer, found)
end
end
end
end

local cachedModule: CachedModule = self._cache[moduleScript:GetFullName()]
local found = {}

getConsumersRecursively(cachedModule, found)

local consumers = {}
for consumer in found do
table.insert(consumers, consumer)
end

return consumers
return self._loader.require(moduleScript)
end

function ModuleLoader:clearModule(moduleToClear: ModuleScript)
if not self._cache[moduleToClear:GetFullName()] then
return
end

local consumers = self:_getConsumers(moduleToClear)
local modulesToClear = { moduleToClear, table.unpack(consumers) }

local index = table.find(modulesToClear, getRobloxTsRuntime())
if index then
table.remove(modulesToClear, index)
end

for _, moduleScript in modulesToClear do
local fullName = moduleScript:GetFullName()

local cachedModule = self._cache[fullName]

if cachedModule then
self._cache[fullName] = nil

for key in cachedModule.globals do
self._globals[key] = nil
end

local janitor = self._janitors[fullName]
janitor:Cleanup()
end
end

for _, moduleScript in modulesToClear do
self._loadedModuleChangedBindable:Fire(moduleScript)
end
self._loader.clearModule(moduleToClear)
end

--[=[
Expand All @@ -344,13 +130,7 @@ end
```
]=]
function ModuleLoader:clear()
self._cache = {}
self._globals = {}

for _, janitor in self._janitors do
janitor:Cleanup()
end
self._janitors = {}
self._loader.clear()
end

return ModuleLoader
Loading
Loading