diff --git a/demo/src/context-test.tsx b/demo/src/context-test.tsx new file mode 100644 index 0000000..a04a4af --- /dev/null +++ b/demo/src/context-test.tsx @@ -0,0 +1,9 @@ +import React, { createContext, useContext } from "@rbxts/react"; + +const layerContext = createContext(0); + +export default () => { + const depth = useContext(layerContext); + + return ; +}; diff --git a/transformer/src/analyzer.ts b/transformer/src/analyzer.ts index d6d8399..93c931c 100644 --- a/transformer/src/analyzer.ts +++ b/transformer/src/analyzer.ts @@ -2,6 +2,7 @@ import * as ts from "typescript"; import { robloxStaticDetector } from "./roblox-bridge"; import type { DependencyInfo, PropEdit, ChildEdit, PatchInstruction, FinePatchBlockInfo } from "./types"; import { EditType } from "./types"; +import { jsxTagExpressionToString } from "./utils"; const BAILOUT_PROP_NAMES = new Set(["ref", "key", "children"]); @@ -62,8 +63,15 @@ export class BlockAnalyzer { // because they can have internal state, effects, hooks, etc. if (tagName[0] && tagName[0] === tagName[0].toUpperCase()) { blockInfo.isStatic = false; - // Add the component itself as a dependency if it's an identifier - blockInfo.dependencies.push(tagName); + // Add the component itself as a dependency + // For PropertyAccessExpression like Ctx.Provider, we only need the base identifier (Ctx) + // Skip if tagName is UnknownTag + if (tagName !== "UnknownTag") { + const baseDependency = tagName.split(".")[0]; + if (baseDependency) { + blockInfo.dependencies.push(baseDependency); + } + } } // Analyze attributes/props @@ -488,11 +496,11 @@ export class BlockAnalyzer { getJsxTagName(node: ts.JsxElement | ts.JsxSelfClosingElement): string { const tagName = ts.isJsxElement(node) ? node.openingElement.tagName : node.tagName; - if (ts.isIdentifier(tagName)) { - return tagName.text; - } - - return "UnknownTag"; + // Use the utility function to convert tag expression to string + const tagString = jsxTagExpressionToString(tagName); + + // Return UnknownTag only if the utility function couldn't identify the tag + return tagString !== "Unknown" ? tagString : "UnknownTag"; } /** diff --git a/transformer/src/index.ts b/transformer/src/index.ts index 1d045dc..0cb079e 100644 --- a/transformer/src/index.ts +++ b/transformer/src/index.ts @@ -9,6 +9,7 @@ import { transformJsxElementWithFinePatch, } from "./transformer"; import type { OptimizationContext, PropInfo, StaticElementInfo } from "./types"; +import { jsxTagExpressionToString } from "./utils"; /** * Configuration options for the Decillion transformer @@ -205,11 +206,11 @@ function shouldSkipFile(file: ts.SourceFile, debug: boolean): boolean { function getTagName(node: ts.JsxElement | ts.JsxSelfClosingElement): string { const tagName = ts.isJsxElement(node) ? node.openingElement.tagName : node.tagName; - if (ts.isIdentifier(tagName)) { - return tagName.text; - } - - return "UnknownTag"; + // Use the utility function to convert tag expression to string + const tagString = jsxTagExpressionToString(tagName); + + // Return UnknownTag only if the utility function couldn't identify the tag + return tagString !== "Unknown" ? tagString : "UnknownTag"; } /** diff --git a/transformer/src/transformer.ts b/transformer/src/transformer.ts index 4c33b6b..32e4f93 100644 --- a/transformer/src/transformer.ts +++ b/transformer/src/transformer.ts @@ -17,16 +17,37 @@ import type { OptimizationContext, PropInfo, StaticElementInfo, TransformResult, * Creates the appropriate tag reference for React.createElement * - Lowercase tags (frame, textlabel) become string literals * - PascalCase tags (Counter, MyComponent) become identifiers + * - Property access (Ctx.Provider) preserves the expression + * - Namespaced names (ns:tag) are converted to string literals */ -function createTagReference(tagName: string): ts.Expression { - // Check if tag name starts with uppercase (PascalCase component) - if (tagName[0] && tagName[0] === tagName[0].toUpperCase()) { - // React component - use identifier - return ts.factory.createIdentifier(tagName); - } else { - // HTML-like element - use string literal - return ts.factory.createStringLiteral(tagName); +function createTagReference(tagName: string | ts.JsxTagNameExpression): ts.Expression { + // If it's a string, convert it to the appropriate form + if (typeof tagName === "string") { + // Check if tag name starts with uppercase (PascalCase component) + if (tagName[0] && tagName[0] === tagName[0].toUpperCase()) { + // React component - use identifier + return ts.factory.createIdentifier(tagName); + } else { + // HTML-like element - use string literal + return ts.factory.createStringLiteral(tagName); + } + } + + // Handle JsxNamespacedName (e.g., ) - convert to string literal + if (ts.isJsxNamespacedName(tagName)) { + return ts.factory.createStringLiteral(`${tagName.namespace.text}:${tagName.name.text}`); } + + // For Identifier, PropertyAccessExpression, or ThisExpression, use them as-is + // These are all valid Expression types that can be used in React.createElement + return tagName as ts.Expression; +} + +/** + * Extracts the tag name expression from a JSX element + */ +function getTagExpression(node: ts.JsxElement | ts.JsxSelfClosingElement): ts.JsxTagNameExpression { + return ts.isJsxElement(node) ? node.openingElement.tagName : node.tagName; } function sanitizeDependencyType( @@ -366,6 +387,9 @@ function generateFinePatchBlock( // Generate patch instructions const finePatchInfo = context.blockAnalyzer!.generatePatchInstructions(node); + // Get the actual tag expression (handles PropertyAccessExpression like Ctx.Provider) + const tagExpression = getTagExpression(node); + // Create the React.createElement call inside the arrow function const createElementCall = ts.factory.createCallExpression( ts.factory.createPropertyAccessExpression( @@ -374,7 +398,7 @@ function generateFinePatchBlock( ), undefined, [ - createTagReference(tagName), + createTagReference(tagExpression), allProps.length > 0 ? createPropsObject(allProps) : ts.factory.createIdentifier("undefined"), ...children, ], @@ -626,6 +650,9 @@ function generateMemoizedBlock( const allProps = extractPropsFromJsx(node); const children = extractOptimizedChildren(node, context); + // Get the actual tag expression (handles PropertyAccessExpression like Ctx.Provider) + const tagExpression = getTagExpression(node); + // Create the React.createElement call inside the arrow function const createElementCall = ts.factory.createCallExpression( ts.factory.createPropertyAccessExpression( @@ -634,7 +661,7 @@ function generateMemoizedBlock( ), undefined, [ - createTagReference(tagName), + createTagReference(tagExpression), allProps.length > 0 ? createPropsObject(allProps) : ts.factory.createIdentifier("undefined"), ...children, ], @@ -754,13 +781,16 @@ function generateOptimizedElement( const propsArg = allProps.length > 0 ? createPropsObject(allProps) : ts.factory.createIdentifier("undefined"); + // Get the actual tag expression (handles PropertyAccessExpression like Ctx.Provider) + const tagExpression = getTagExpression(node); + const element = ts.factory.createCallExpression( ts.factory.createPropertyAccessExpression( ts.factory.createIdentifier("React"), ts.factory.createIdentifier("createElement"), ), undefined, - [createTagReference(tagName), propsArg, ...children], + [createTagReference(tagExpression), propsArg, ...children], ); return { diff --git a/transformer/src/utils.ts b/transformer/src/utils.ts new file mode 100644 index 0000000..cbef2c0 --- /dev/null +++ b/transformer/src/utils.ts @@ -0,0 +1,44 @@ +import * as ts from "typescript"; + +/** + * Shared utility functions for JSX transformation + */ + +/** + * Converts a JSX tag name expression to a string representation. + * Handles PropertyAccessExpression recursively (e.g., Ctx.Provider -> "Ctx.Provider") + * Handles ThisExpression (e.g., -> "this.Component") + */ +export function jsxTagExpressionToString(expr: ts.JsxTagNameExpression): string { + if (ts.isIdentifier(expr)) { + return expr.text; + } else if (ts.isPropertyAccessExpression(expr)) { + // For PropertyAccessExpression, recursively process the base expression + // In JSX context, expr.expression should be another valid JSX tag name expression + // (Identifier, PropertyAccessExpression, or ThisExpression) + const baseExpr = expr.expression; + let baseString: string; + + if (ts.isIdentifier(baseExpr)) { + baseString = baseExpr.text; + } else if (ts.isPropertyAccessExpression(baseExpr)) { + // Recursively handle nested property access (e.g., a.b.c) + // Type assertion is safe here because we know it's a PropertyAccessExpression + baseString = jsxTagExpressionToString(baseExpr as ts.JsxTagNameExpression); + } else if ((baseExpr as ts.Node).kind === ts.SyntaxKind.ThisKeyword) { + baseString = "this"; + } else { + // Fallback for unexpected expression types + baseString = "Unknown"; + } + + return baseString + "." + expr.name.text; + } else if ((expr as ts.Node).kind === ts.SyntaxKind.ThisKeyword) { + // Handle ThisExpression + return "this"; + } else if (ts.isJsxNamespacedName(expr)) { + // Handle namespaced names (e.g., ) + return expr.namespace.text + ":" + expr.name.text; + } + return "Unknown"; +} diff --git a/transformer/test/transformer.integration.test.ts b/transformer/test/transformer.integration.test.ts index 69d36da..21daf01 100644 --- a/transformer/test/transformer.integration.test.ts +++ b/transformer/test/transformer.integration.test.ts @@ -228,4 +228,79 @@ export function UsesState() { const reactIndex = output.indexOf("@rbxts/react"); expect(runtimeIndex).toBeLessThan(reactIndex); }); + + it("handles Context.Provider components correctly", () => { + const source = ` +import React, { createContext, useContext } from "@rbxts/react"; + +const layerContext = createContext(0); + +export default () => { + const depth = useContext(layerContext); + + return ; +}; +`; + const output = transformSource(source); + + // Should not contain UnknownTag + expect(output).not.toContain("UnknownTag"); + + // Should generate React.createElement with layerContext.Provider + expect(output).toContain("React.createElement"); + expect(output).toContain("layerContext.Provider"); + }); + + it("handles React.Fragment correctly", () => { + const source = ` +import React from "@rbxts/react"; + +export function FragmentExample({ items }: { items: string[] }) { + return ( + + {items.map((item) => ( + + ))} + + ); +} +`; + const output = transformSource(source); + + // Should not contain UnknownTag + expect(output).not.toContain("UnknownTag"); + + // Should generate React.createElement with React.Fragment + expect(output).toContain("React.createElement"); + expect(output).toContain("React.Fragment"); + }); + + it("does not treat Provider with dynamic props as static", () => { + const source = ` +import React, { createContext } from "@rbxts/react"; + +const ThemeContext = createContext("light"); + +export function ThemeProvider({ theme, children }: { theme: string; children: React.ReactNode }) { + return {children}; +} +`; + const output = transformSource(source); + + // Should not contain UnknownTag + expect(output).not.toContain("UnknownTag"); + + // Should generate React.createElement with ThemeContext.Provider + expect(output).toContain("React.createElement"); + expect(output).toContain("ThemeContext.Provider"); + + // Should NOT call createStaticElement with the Provider tag + // (it may appear in imports, but should not be called for this component) + expect(output).not.toMatch(/createStaticElement\([^)]*ThemeContext\.Provider/); + + // Should use useFinePatchBlock or regular createElement for dynamic props + const hasFinePatch = output.includes("useFinePatchBlock"); + const hasCreateElement = output.includes("React.createElement(ThemeContext.Provider"); + expect(hasFinePatch || hasCreateElement).toBe(true); + }); });