Short version: If you’re okay with some rules/restrictions, this is very doable. If you want it to work with “totally arbitrary JS inside the component”, it gets hard (full control-flow/data-flow analysis territory).
Let me break it down in terms of what you probably want for Rask, and what the compiler would actually have to do.
⸻
What you have vs what you want
Today:
function MyComp() { // Reactive scope (runs once on mount) const state = createState({})
// Reconciling scope (runs on each “render”) return () => (
What you want users to write:
function MyComp() { const state = createState({})
return
And have the compiler turn that into something equivalent to:
function MyComp() { const state = createState({})
const view = () => { return
return view }
Plus support for:
function MyComp() { const state = createState({})
if (state.visible) { return } else { return } }
…which should become something like:
function MyComp() { const state = createState({})
const view = () => { if (state.visible) { return } else { return } }
return view }
So the “compiler problem” is:
Given a component function, split it into: • setup (runs once) • view (a function that runs on each reconcile), and preserve semantics.
⸻
A practical strategy that isn’t insane
If you embrace a few simple rules, you can get away with a pretty clean transform and not a research project.
- Define some “reactive primitives”
Start by deciding which calls mark “reactive setup”: • createState • createSignal • createEffect • etc.
Your plugin looks for those at the top level of the component body (not inside loops/ifs/nested functions).
- (“Rules of Rask Components” – like rules of hooks)
If you enforce something like: 1. createState/friends must be: • in the component’s top-level scope • not in conditionals/loops/try/finally/nested funcs. 2. The component can return JSX anywhere (simple if, switch, ternary, early returns) – but not from inside inner functions. 3. Top-level code is “setup”; code in the execution path that leads to a return is “view”.
You can then do an 80/20 transform that’s not too insane.
⸻
Minimal viable transform (surprisingly simple)
You can get a working version with a very simple approach: 1. Detect component functions: • Name starts with capital • or it returns JSX • or it calls createState etc. 2. Inside that function: • Collect all top-level declarations that look like “setup”: • variable declarations whose initializer is: • a call to a reactive primitive (must be setup) • or a function/expression that doesn’t contain JSX and doesn’t return JSX. • Everything else (control flow, returns, JSX) goes inside the view function.
Pseudocode (conceptually):
function transformComponent(fn) { const body = fn.body.body // array of statements const setup: Statement[] = [] const view: Statement[] = []
for (const stmt of body) {
if (isReactiveInit(stmt)) {
setup.push(stmt)
} else if (isPureSetup(stmt)) {
// e.g. const handleClick = () => { state.count++ }
// no JSX, no return at this level
setup.push(stmt)
} else {
view.push(stmt)
}
}
fn.body.body = [ ...setup, t.variableDeclaration("const", [ t.variableDeclarator( t.identifier("__view"), t.arrowFunctionExpression([], t.blockStatement(view)) ) ]), t.returnStatement(t.identifier("__view")) ] }
This already handles:
function MyComp() { const state = createState({ count: 0 })
const handleClick = () => { state.count++ }
if (state.count > 0) { return
return
Becomes:
function MyComp() { const state = createState({ count: 0 })
const handleClick = () => { state.count++ }
const __view = () => { if (state.count > 0) { return
return <div>Zero</div>
}
return __view }
Which has the semantics you want: • createState & handler defined once. • __view() re-runs, reading state.count.
What about returns inside if/switch?
That falls out automatically, because you’re just wrapping all those statements into the body of __view. You don’t have to analyze control flow deeply as long as: • All returns that matter are at top level (not inside nested functions).
⸻
Where it starts to get tricky
It becomes significantly more complex if you want to support patterns like:
function MyComp() { const state = createState({ count: 0 })
const doubled = state.count * 2 // you want this to be re-evaluated on each render
if (doubled > 10) { return } return }
You have two options:
Option A – Simple rule: no reactive reads in top-level expressions
You say:
At the top level of the component body, you can create reactive things, define handlers, etc., but you may not read reactive state directly. Reactive reads must be inside the JSX/return logic.
Then users are supposed to write:
const doubled = () => state.count * 2
if (doubled() > 10) ...
or just:
if (state.count * 2 > 10) ...
This keeps the transform simple: any statement that reads a “reactive symbol” at top level is automatically classified as view (or it’s a compile-time error). No heavy analysis.
Option B – Fully smart data-flow analysis
If you don’t impose that restriction, then you need to: • Build a control-flow graph for the component body. • Track which statements must re-run on each “render”, based on: • they read reactive variables, or • they lead to a return of JSX, etc. • Split declarations if necessary (e.g. hoisting function declarations while keeping their invocation in view code).
This quickly looks like a mini compiler project, not just a macro transform.
⸻
So how “complex” is this, really?
With constraints (hooks-like rules + no top-level reactive reads): • Conceptually straightforward: • You’re basically doing: “hoist obvious setup, wrap the rest in __view”. • Implementation-wise: • A Babel/TS/SWC plugin with a few passes: 1. Find reactive primitives & mark their bindings. 2. Classify statements into setup vs view using simple structural rules. 3. Rebuild the function body as setup + const __view = () => { view } + return __view.
You’ll mostly fight with: • Edge cases like return someCondition && ; • Arrow functions vs function declarations; • Ensuring you don’t accidentally move return into setup.
But it’s all local to a single function; no cross-file analysis.
Without constraints (arbitrary JS, arbitrary reactive reads): • You’re into: • control-flow graphs • use/def chains • potentially needing to split/bisect complex statements. • Still doable, but complexity ramps up quite a bit.
⸻
My recommendation for Rask
Given what you’re doing with Rask (and that it’s nicely opinionated already): 1. Define “Rules of Rask Components” (like Rules of Hooks). E.g.: • All reactive primitives must be top-level. • No reading reactive state at top-level (outside view) – or make that a hard compile error. • return only at top level of the component body (can be inside if, switch, ternaries, but not inside nested functions). 2. Implement the simple “wrap + hoist” transform first: • Hoist reactive initializers + non-JSX helper declarations. • Wrap everything else in __view and return __view. 3. Iterate only if needed: • If you later find real-world patterns you want to support (e.g. derived values at top level), you can extend the analysis.
That path keeps it approachable, fits the mental model of “reactive setup + reconciling view”, and doesn’t require building a full-blown optimizer.
If you want, I can sketch the actual Babel/SWC plugin structure and the concrete classification rules in code next.