Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 6 additions & 2 deletions transformer/src/analyzer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,21 @@ export interface BlockInfo {
export class BlockAnalyzer {
private blockCounter = 0;
private blocks = new Map<ts.Node, BlockInfo>();
private readonly bailoutPropNames: Set<string>;

constructor(
private typeChecker: ts.TypeChecker,
private context: ts.TransformationContext,
private program?: ts.Program,
private debug = false,
customBailoutProps: Set<string> = new Set(),
) {
// Initialize the Roblox static detector
if (program) {
robloxStaticDetector.initialize(program, debug);
}

this.bailoutPropNames = new Set([...BAILOUT_PROP_NAMES, ...customBailoutProps]);
}

/**
Expand Down Expand Up @@ -73,7 +77,7 @@ export class BlockAnalyzer {
if (!attr.initializer) {
const propName = ts.isIdentifier(attr.name) ? attr.name.text : attr.name.getText();
const normalized = propName.toLowerCase();
if (BAILOUT_PROP_NAMES.has(normalized)) {
if (this.bailoutPropNames.has(normalized)) {
blockInfo.hasNonOptimizableProps = true;
blockInfo.isStatic = false;
continue;
Expand All @@ -86,7 +90,7 @@ export class BlockAnalyzer {
const propName = ts.isIdentifier(attr.name) ? attr.name.text : attr.name.getText();
const normalized = propName.toLowerCase();

if (BAILOUT_PROP_NAMES.has(normalized)) {
if (this.bailoutPropNames.has(normalized)) {
blockInfo.hasNonOptimizableProps = true;
blockInfo.isStatic = false;

Expand Down
81 changes: 75 additions & 6 deletions transformer/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import {
shouldSkipTransformation,
transformJsxElementWithFinePatch,
} from "./transformer";
import type { OptimizationContext, PropInfo, StaticElementInfo } from "./types";
import type { DisabledOptimizationOptions, OptimizationContext, PropInfo, StaticElementInfo } from "./types";
import { resolveDisabledOptimizations } from "./types";

/**
* Configuration options for the Decillion transformer
Expand All @@ -20,14 +21,17 @@ export interface DecillionTransformerOptions {
signatureMessage?: string;
/** Enable debug logging */
debug?: boolean;
/** Configuration for disabling specific optimization features */
disabledOptimizations?: DisabledOptimizationOptions;
}

/**
* Million.js-inspired TypeScript transformer for Roblox-TS
* Transforms JSX into highly optimized, block-memoized UI code
*/
export default function (program: ts.Program, options: DecillionTransformerOptions = {}) {
const { addSignature = true, signatureMessage, debug = false } = options;
const { addSignature = true, signatureMessage, debug = false, disabledOptimizations } = options;
const resolvedDisabledOptimizations = resolveDisabledOptimizations(disabledOptimizations);

return (context: ts.TransformationContext): ((file: ts.SourceFile) => ts.Node) => {
return (file: ts.SourceFile) => {
Expand All @@ -46,22 +50,40 @@ export default function (program: ts.Program, options: DecillionTransformerOptio
}

// Initialize transformation context with the new architecture
const blockAnalyzer = new BlockAnalyzer(program.getTypeChecker(), context, program, debug);
const transformer = new DecillionTransformer(program.getTypeChecker(), context, blockAnalyzer);
const blockAnalyzer = new BlockAnalyzer(
program.getTypeChecker(),
context,
program,
debug,
resolvedDisabledOptimizations.props,
);
const transformer = new DecillionTransformer(
program.getTypeChecker(),
context,
blockAnalyzer,
resolvedDisabledOptimizations,
);
const optimizationContext = transformer.getContext();

let needsRuntimeImport = false;

// First pass: scan for functions with @undecillion decorator
const functionStack: (string | undefined)[] = [];

const scanVisitor = (node: ts.Node): void => {
let pushed = false;

if (
ts.isFunctionDeclaration(node) ||
ts.isFunctionExpression(node) ||
ts.isArrowFunction(node) ||
ts.isMethodDeclaration(node)
) {
const functionName = getFunctionName(node);
functionStack.push(functionName);
pushed = true;

if (hasUndecillionDecorator(node, file)) {
const functionName = getFunctionName(node);
if (functionName) {
optimizationContext.skipTransformFunctions.add(functionName);
if (debug) {
Expand All @@ -71,7 +93,26 @@ export default function (program: ts.Program, options: DecillionTransformerOptio
}
}

if (ts.isCallExpression(node) && resolvedDisabledOptimizations.hooks.size > 0) {
const hookName = getCallExpressionIdentifier(node);
if (hookName && resolvedDisabledOptimizations.hooks.has(hookName)) {
const activeFunction = getActiveFunctionName(functionStack);
if (activeFunction) {
optimizationContext.forceBasicTransformFunctions.add(activeFunction);
if (debug) {
console.log(
`Disabling advanced optimizations for ${activeFunction} due to hook ${hookName}`,
);
}
}
}
Comment on lines +96 to +108

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Hook bailouts miss anonymous components

We only ever push function names onto functionStack, so getActiveFunctionName returns undefined for anonymous functions (e.g. export default () => { useContext(...) }). That means the new hook-based bailout never trips for those components, and advanced optimizations still run even though useContext is in disabledOptimizations.hooks. This breaks the main feature for a very common pattern. Please track the actual function nodes (or another unique identifier) instead of just names when pushing/popping, and store those identifiers in forceBasicTransformFunctions so anonymous/default exports are covered.

}

ts.forEachChild(node, scanVisitor);

if (pushed) {
functionStack.pop();
}
};

// Scan the file first
Expand Down Expand Up @@ -121,9 +162,12 @@ export default function (program: ts.Program, options: DecillionTransformerOptio
console.log(`Found JSX element: ${getTagName(node)}`);
}

needsRuntimeImport = true;
const result = transformJsxElementWithFinePatch(node, optimizationContext);

if (result.needsRuntimeImport) {
needsRuntimeImport = true;
}

// Store any static elements that were generated
if (result.staticElement) {
optimizationContext.staticElements.set(result.staticElement.id, result.staticElement);
Expand Down Expand Up @@ -212,6 +256,31 @@ function getTagName(node: ts.JsxElement | ts.JsxSelfClosingElement): string {
return "UnknownTag";
}

function getCallExpressionIdentifier(callExpression: ts.CallExpression): string | undefined {
const expression = callExpression.expression;

if (ts.isIdentifier(expression)) {
return expression.text;
}

if (ts.isPropertyAccessExpression(expression) && ts.isIdentifier(expression.name)) {
return expression.name.text;
}

return undefined;
}

function getActiveFunctionName(stack: (string | undefined)[]): string | undefined {
for (let index = stack.length - 1; index >= 0; index--) {
const name = stack[index];
if (name) {
return name;
}
}

return undefined;
}

/**
* Applies post-transformation modifications (imports, static props, signatures)
*/
Expand Down
42 changes: 35 additions & 7 deletions transformer/src/transformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,16 @@ import {
createDependenciesArray,
} from "./codegen";
import { robloxStaticDetector } from "./roblox-bridge";
import type { OptimizationContext, PropInfo, StaticElementInfo, TransformResult, PatchInstruction } from "./types";
import type {
DisabledOptimizationOptions,
OptimizationContext,
PropInfo,
ResolvedDisabledOptimizationOptions,
StaticElementInfo,
TransformResult,
PatchInstruction,
} from "./types";
import { isResolvedDisabledOptimizations, resolveDisabledOptimizations } from "./types";

/**
* Creates the appropriate tag reference for React.createElement
Expand Down Expand Up @@ -285,7 +294,12 @@ export class DecillionTransformer {
typeChecker: ts.TypeChecker,
transformationContext: ts.TransformationContext,
blockAnalyzer?: BlockAnalyzer,
disabledOptimizations?: DisabledOptimizationOptions | ResolvedDisabledOptimizationOptions,
) {
const resolvedDisabledOptimizations = isResolvedDisabledOptimizations(disabledOptimizations)
? disabledOptimizations
: resolveDisabledOptimizations(disabledOptimizations);

this.context = {
typeChecker,
context: transformationContext,
Expand All @@ -297,8 +311,10 @@ export class DecillionTransformer {
blockAnalyzer,
skipTransformFunctions: new Set<string>(),
functionContextStack: [],
forceBasicTransformFunctions: new Set<string>(),
tagToInstanceNameMap: robloxStaticDetector.getTagToInstanceNameMap(),
requiredTypeImports: new Set<string>(),
disabledOptimizations: resolvedDisabledOptimizations,
};
}

Expand Down Expand Up @@ -329,11 +345,14 @@ export function transformJsxElementWithFinePatch(
node: ts.JsxElement | ts.JsxSelfClosingElement,
context: OptimizationContext,
): TransformResult {
const transformer = new DecillionTransformer(context.typeChecker, context.context, context.blockAnalyzer);

const blockInfo = transformer.analyzeJsxElement(node);
const tagName = context.blockAnalyzer!.getJsxTagName(node);

if (shouldUseBasicTransform(context)) {
return generateOptimizedElement(node, tagName, context);
}

const blockInfo = context.blockAnalyzer!.analyzeJsxElement(node);

if (blockInfo.hasNonOptimizableProps) {
return generateOptimizedElement(node, tagName, context);
}
Expand Down Expand Up @@ -510,11 +529,14 @@ export function transformJsxElement(
node: ts.JsxElement | ts.JsxSelfClosingElement,
context: OptimizationContext,
): TransformResult {
const transformer = new DecillionTransformer(context.typeChecker, context.context, context.blockAnalyzer);

const blockInfo = transformer.analyzeJsxElement(node);
const tagName = context.blockAnalyzer!.getJsxTagName(node);

if (shouldUseBasicTransform(context)) {
return generateOptimizedElement(node, tagName, context);
}

const blockInfo = context.blockAnalyzer!.analyzeJsxElement(node);

if (blockInfo.isStatic) {
return generateStaticElement(node, tagName, context);
}
Expand Down Expand Up @@ -1055,3 +1077,9 @@ export function getFunctionName(
export function shouldSkipTransformation(context: OptimizationContext): boolean {
return context.functionContextStack.some((functionName) => context.skipTransformFunctions.has(functionName));
}

export function shouldUseBasicTransform(context: OptimizationContext): boolean {
return context.functionContextStack.some((functionName) =>
context.forceBasicTransformFunctions.has(functionName),
);
}
52 changes: 52 additions & 0 deletions transformer/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,54 @@
import * as ts from "typescript";
import type { BlockAnalyzer } from "./analyzer";

export interface DisabledOptimizationOptions {
hooks?: string[];
props?: string[];
}

export interface ResolvedDisabledOptimizationOptions {
hooks: Set<string>;
props: Set<string>;
}

export function resolveDisabledOptimizations(
options: DisabledOptimizationOptions | undefined,
): ResolvedDisabledOptimizationOptions {
const hooks = new Set<string>();
const props = new Set<string>();

if (options?.hooks) {
for (const hook of options.hooks) {
const trimmed = hook.trim();
if (trimmed) {
hooks.add(trimmed);
}
}
}

if (options?.props) {
for (const prop of options.props) {
const normalized = prop.trim().toLowerCase();
if (normalized) {
props.add(normalized);
}
}
}

return { hooks, props };
}

export function isResolvedDisabledOptimizations(
value: DisabledOptimizationOptions | ResolvedDisabledOptimizationOptions | undefined,
): value is ResolvedDisabledOptimizationOptions {
return (
value !== undefined &&
value !== null &&
value.hooks instanceof Set &&
value.props instanceof Set
);
}

/**
* Core types for block analysis and transformation
*/
Expand Down Expand Up @@ -95,8 +143,12 @@ export interface OptimizationContext {
skipTransformFunctions: Set<string>;
/** Stack of current function context to track if we're inside a skip function */
functionContextStack: string[];
/** Set of functions that should use the basic transformation pipeline due to disabled features */
forceBasicTransformFunctions: Set<string>;
/** Map of tag names to Roblox instance names */
tagToInstanceNameMap: Map<string, string>;
/** Type-only imports required from the runtime */
requiredTypeImports: Set<string>;
/** Resolved disabled optimization configuration */
disabledOptimizations: ResolvedDisabledOptimizationOptions;
}
Loading