Skip to content

RFC: Support cyclic requires.#205

Open
andyfriesen wants to merge 2 commits into
masterfrom
cyclic-requires
Open

RFC: Support cyclic requires.#205
andyfriesen wants to merge 2 commits into
masterfrom
cyclic-requires

Conversation

@andyfriesen
Copy link
Copy Markdown
Collaborator

@andyfriesen andyfriesen commented May 11, 2026

Rendered

We augment the require() function, the module interface, and the type checker to allow modules to cyclically require one another in many cases.

We augment the require() function, the module interface, and the type checker to allow
modules to cyclically require one another in many cases.
Comment on lines +65 to +67
2. Look up the requested module in the cache to see if it has already been loaded or begun loading
3. If a module is in the cache, return it immediately. Otherwise,
4. Populate the cache with an empty table.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be better to indicate this is really one step with two alternatives.

2. Look up the requested module in the cache to see if it has already been loaded or begun loading
3. If a module is in the cache, return it immediately. Otherwise,
4. Populate the cache with an empty table.
5. Pass this new table to the target script as its sole argument and evaluate it. This table can be accessed within the script via `...` at the top level.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are there any backwards compatibility issues here?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how would hot-reloading a module affect everything, since it talks about cache


We will augment this class to instead work on one strongly-connected component\* at a time. All modules within an SCC use the same arena and are typechecked together in a single pass through the solver.

\* A "strongly connected component" is a set of modules that all mutually `require()` one another.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wording nit: if I didn't know what an SCC was (or forgot because it's been a bit since my graph theory class), I might assume this only meant direct mutually recursive requires, e.g.:

--- A.luau
local B = require('B')
--- B.luau
local A = require('A')

... and not just any set of modules where every one depends on every other via some chain of requires.

Comment on lines +203 to +219
For instance, the following code will fail:

```luau
--- A.luau

local B = require("B")

class ClassAOne extends B.ClassOne ... end
class ClassATwo ... end

--- B.luau

local A = require("A")

class ClassBOne ... end
class ClassBTwo extends A.ClassATwo ... end
```
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Food for thought, I think this would get tricker with a (sketched out) import syntax:

--- A.luau

import ClassBOne from B

export class ClassAOne extends ClassBOne ... end
export class ClassATwo ... end

--- B.luau

import ClassATwo from A

class ClassBOne ... end
class ClassBTwo extends ClassATwo ... end

... if we're considering this a serious drawback, this seems like the real danger.


Today, typechecking is driven by a class called `Frontend`. It accepts a set of modules that need checking, builds a DAG from that, and checks modules one after another.

We will augment this class to instead work on one strongly-connected component\* at a time. All modules within an SCC use the same arena and are typechecked together in a single pass through the solver.
Copy link
Copy Markdown
Contributor

@alexmccord alexmccord May 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could also keep them separate, and instead dispatch one constraint solver in the same component, and dispatch the next one if that one is stuck, and so on, until either all of them completes or all of them are stuck.

This would avoid a constraint from module A making its way into some random metavariable's bounds in module B.

@MagmaBurnsV
Copy link
Copy Markdown
Contributor

The ceremony required to make modules that don't use static exports participate in cycles seems silly to me. As the RFC mentions, cycles are already rare for projects prior to classes, and since classes go hand-in-hand with static exports, I see no sense in adding an escape hatch for this edge case.

Just support cycles only for modules that use static exports. That way you don't even have to worry about if a module returns a different value since it's impossible.

In the absence of `export`, a script must be updated to support cyclic requires by making a small edit: Instead of creating an export table directly with `{}`, the script should accept it from `...` like so:

```luau
local exports = ...
Copy link
Copy Markdown
Contributor

@karl-police karl-police May 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, this is GETVARARGS. Now, the thing I know about it, is that it's often used within function bodies.

Doing local var = ... would mean that it's taking 1 element from the var arg alone.

If I do local a,b,c = ... it would take 3 and not more.

What would the design have to change here? Or what would this syntax mean if it is within function bodies?
Or does nothing change about GETVARARGS and it will stay 1 element, it's just to accept the export table

@karl-police
Copy link
Copy Markdown
Contributor

karl-police commented May 12, 2026

Will there be a new kind of type?

I know there's modules that do the following

-- now we could require("Main.luau") for the varargs?
--- A.luau
return function (varargs)
   print(varargs.OurEnvironment) -- inheritance from upper module, I guess.
end

but I guess cyclic requires would solve this too, e.g. for Require Tracer?

-- Main.luau
local PortionA = require("A.luau")

local Main = {
  OurEnvironment = {}
}

PortionA(Main)

@VegetationBush
Copy link
Copy Markdown

Wouldn't it be better if a separate keyword was used instead (i.e., requireDeferred, lazyRequire)? This allows require to stay pure instead of throwing new kinds of errors, or worrying about a workaround to capture tuples after returning the cyclic metatable.

A second benefit to this is that it handles the case where A immediately requires B but B does not immediately require A (which I believe is an edge case that wasn't considered in this RFC).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants