diff --git a/transformer/src/analyzer.ts b/transformer/src/analyzer.ts index d6d8399..971e315 100644 --- a/transformer/src/analyzer.ts +++ b/transformer/src/analyzer.ts @@ -23,17 +23,21 @@ export interface BlockInfo { export class BlockAnalyzer { private blockCounter = 0; private blocks = new Map(); + private readonly bailoutPropNames: Set; constructor( private typeChecker: ts.TypeChecker, private context: ts.TransformationContext, private program?: ts.Program, private debug = false, + customBailoutProps: Set = new Set(), ) { // Initialize the Roblox static detector if (program) { robloxStaticDetector.initialize(program, debug); } + + this.bailoutPropNames = new Set([...BAILOUT_PROP_NAMES, ...customBailoutProps]); } /** @@ -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; @@ -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; diff --git a/transformer/src/index.ts b/transformer/src/index.ts index 1d045dc..ce62264 100644 --- a/transformer/src/index.ts +++ b/transformer/src/index.ts @@ -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 @@ -20,6 +21,8 @@ export interface DecillionTransformerOptions { signatureMessage?: string; /** Enable debug logging */ debug?: boolean; + /** Configuration for disabling specific optimization features */ + disabledOptimizations?: DisabledOptimizationOptions; } /** @@ -27,7 +30,8 @@ export interface DecillionTransformerOptions { * 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) => { @@ -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) { @@ -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}`, + ); + } + } + } + } + ts.forEachChild(node, scanVisitor); + + if (pushed) { + functionStack.pop(); + } }; // Scan the file first @@ -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); @@ -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) */ diff --git a/transformer/src/transformer.ts b/transformer/src/transformer.ts index 4c33b6b..6e74be0 100644 --- a/transformer/src/transformer.ts +++ b/transformer/src/transformer.ts @@ -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 @@ -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, @@ -297,8 +311,10 @@ export class DecillionTransformer { blockAnalyzer, skipTransformFunctions: new Set(), functionContextStack: [], + forceBasicTransformFunctions: new Set(), tagToInstanceNameMap: robloxStaticDetector.getTagToInstanceNameMap(), requiredTypeImports: new Set(), + disabledOptimizations: resolvedDisabledOptimizations, }; } @@ -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); } @@ -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); } @@ -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), + ); +} diff --git a/transformer/src/types.ts b/transformer/src/types.ts index 2b5c2e8..54de928 100644 --- a/transformer/src/types.ts +++ b/transformer/src/types.ts @@ -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; + props: Set; +} + +export function resolveDisabledOptimizations( + options: DisabledOptimizationOptions | undefined, +): ResolvedDisabledOptimizationOptions { + const hooks = new Set(); + const props = new Set(); + + 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 */ @@ -95,8 +143,12 @@ export interface OptimizationContext { skipTransformFunctions: Set; /** 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; /** Map of tag names to Roblox instance names */ tagToInstanceNameMap: Map; /** Type-only imports required from the runtime */ requiredTypeImports: Set; + /** Resolved disabled optimization configuration */ + disabledOptimizations: ResolvedDisabledOptimizationOptions; } diff --git a/transformer/test/transformer.integration.test.ts b/transformer/test/transformer.integration.test.ts index 69d36da..749f06d 100644 --- a/transformer/test/transformer.integration.test.ts +++ b/transformer/test/transformer.integration.test.ts @@ -1,6 +1,6 @@ import * as ts from "typescript"; import { describe, expect, it } from "vitest"; -import decillionTransformer from "../src/index"; +import decillionTransformer, { type DecillionTransformerOptions } from "../src/index"; function createProgramWithSource(code: string) { const sourceFile = ts.createSourceFile("test.tsx", code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); @@ -30,9 +30,13 @@ function createProgramWithSource(code: string) { return { program, sourceFile }; } -function transformSource(code: string): string { +function transformSource(code: string, options?: DecillionTransformerOptions): string { const { program, sourceFile } = createProgramWithSource(code); - const transformer = decillionTransformer(program, { addSignature: false, debug: false }); + const transformer = decillionTransformer(program, { + addSignature: false, + debug: false, + ...(options ?? {}), + }); const transformerFactory = transformer as ts.TransformerFactory; const { transformed } = ts.transform(sourceFile, [transformerFactory]); const transformedFile = transformed[0] as ts.SourceFile; @@ -173,6 +177,46 @@ export function WithRef() { expect(output).toContain("ref: frameRef"); }); + it("disables advanced optimizations for configured hooks like useContext", () => { + const source = ` +import React, { useContext } from "@rbxts/react"; + +const ThemeContext = {} as never; + +export function WithContext() { + const theme = useContext(ThemeContext); + return ; +} +`; + + const output = transformSource(source, { + disabledOptimizations: { hooks: ["useContext"] }, + }); + + expect(output).toContain("React.createElement"); + expect(output).not.toContain("createStaticElement"); + expect(output).not.toContain("useFinePatchBlock"); + }); + + it("supports disabling optimizations for multiple configured hooks", () => { + const source = ` +import React, { useRef } from "@rbxts/react"; + +export function WithRefValue() { + const value = useRef(0); + return ; +} +`; + + const output = transformSource(source, { + disabledOptimizations: { hooks: ["useRef"] }, + }); + + expect(output).toContain("React.createElement"); + expect(output).not.toContain("createStaticElement"); + expect(output).not.toContain("useFinePatchBlock"); + }); + it("emits event patch instructions for event handlers", () => { const source = ` export function WithEvents({ onClick }: { onClick: () => void }) {