From 69eccc9326b80d07d552795696d9461e9aebacb3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Feb 2026 11:03:23 +0000 Subject: [PATCH 1/7] Initial plan From 28af96c10539b5d8be66d0af980dba39da085419 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Feb 2026 11:10:51 +0000 Subject: [PATCH 2/7] Fix Context.Provider support by handling PropertyAccessExpression in JSX tags Co-authored-by: evilbocchi <71329833+evilbocchi@users.noreply.github.com> --- transformer/src/analyzer.ts | 22 ++++++++++++-- transformer/src/index.ts | 14 +++++++++ transformer/src/transformer.ts | 30 ++++++++++++++++--- .../test/transformer.integration.test.ts | 22 ++++++++++++++ 4 files changed, 82 insertions(+), 6 deletions(-) diff --git a/transformer/src/analyzer.ts b/transformer/src/analyzer.ts index d6d8399..dedc58d 100644 --- a/transformer/src/analyzer.ts +++ b/transformer/src/analyzer.ts @@ -62,8 +62,12 @@ 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) + const baseDependency = tagName.split(".")[0]; + if (baseDependency) { + blockInfo.dependencies.push(baseDependency); + } } // Analyze attributes/props @@ -492,6 +496,20 @@ export class BlockAnalyzer { return tagName.text; } + // Handle PropertyAccessExpression (e.g., Ctx.Provider, React.Fragment) + if (ts.isPropertyAccessExpression(tagName)) { + // Return a string representation for ID generation + const getText = (expr: ts.Expression): string => { + if (ts.isIdentifier(expr)) { + return expr.text; + } else if (ts.isPropertyAccessExpression(expr)) { + return getText(expr.expression) + "." + expr.name.text; + } + return "Unknown"; + }; + return getText(tagName.expression) + "." + tagName.name.text; + } + return "UnknownTag"; } diff --git a/transformer/src/index.ts b/transformer/src/index.ts index 1d045dc..97833b8 100644 --- a/transformer/src/index.ts +++ b/transformer/src/index.ts @@ -209,6 +209,20 @@ function getTagName(node: ts.JsxElement | ts.JsxSelfClosingElement): string { return tagName.text; } + // Handle PropertyAccessExpression (e.g., Ctx.Provider, React.Fragment) + if (ts.isPropertyAccessExpression(tagName)) { + // Return a string representation for logging + const getText = (expr: ts.Expression): string => { + if (ts.isIdentifier(expr)) { + return expr.text; + } else if (ts.isPropertyAccessExpression(expr)) { + return getText(expr.expression) + "." + expr.name.text; + } + return "Unknown"; + }; + return getText(tagName.expression) + "." + tagName.name.text; + } + return "UnknownTag"; } diff --git a/transformer/src/transformer.ts b/transformer/src/transformer.ts index 4c33b6b..ad11489 100644 --- a/transformer/src/transformer.ts +++ b/transformer/src/transformer.ts @@ -17,8 +17,14 @@ 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 */ -function createTagReference(tagName: string): ts.Expression { +function createTagReference(tagName: string | ts.Expression): ts.Expression { + // If already an expression (e.g., PropertyAccessExpression), use it as-is + if (typeof tagName !== "string") { + return tagName; + } + // Check if tag name starts with uppercase (PascalCase component) if (tagName[0] && tagName[0] === tagName[0].toUpperCase()) { // React component - use identifier @@ -29,6 +35,13 @@ function createTagReference(tagName: string): 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( dep: string, typeNode: ts.TypeNode | undefined, @@ -366,6 +379,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 +390,7 @@ function generateFinePatchBlock( ), undefined, [ - createTagReference(tagName), + createTagReference(tagExpression), allProps.length > 0 ? createPropsObject(allProps) : ts.factory.createIdentifier("undefined"), ...children, ], @@ -626,6 +642,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 +653,7 @@ function generateMemoizedBlock( ), undefined, [ - createTagReference(tagName), + createTagReference(tagExpression), allProps.length > 0 ? createPropsObject(allProps) : ts.factory.createIdentifier("undefined"), ...children, ], @@ -754,13 +773,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/test/transformer.integration.test.ts b/transformer/test/transformer.integration.test.ts index 69d36da..eaf0218 100644 --- a/transformer/test/transformer.integration.test.ts +++ b/transformer/test/transformer.integration.test.ts @@ -228,4 +228,26 @@ 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"); + }); }); From a8aa770f77ec6e721e455fa43842d1d37ee105fe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Feb 2026 11:12:47 +0000 Subject: [PATCH 3/7] Fix TypeScript compilation errors for JsxTagNameExpression handling Co-authored-by: evilbocchi <71329833+evilbocchi@users.noreply.github.com> --- transformer/src/transformer.ts | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/transformer/src/transformer.ts b/transformer/src/transformer.ts index ad11489..32e4f93 100644 --- a/transformer/src/transformer.ts +++ b/transformer/src/transformer.ts @@ -18,21 +18,29 @@ import type { OptimizationContext, PropInfo, StaticElementInfo, TransformResult, * - 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): ts.Expression { - // If already an expression (e.g., PropertyAccessExpression), use it as-is - if (typeof tagName !== "string") { - return 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); + } } - // 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; } /** From b9a1218e75215e51cd8f15f3e5fcf8710c00c169 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Feb 2026 11:16:00 +0000 Subject: [PATCH 4/7] Refactor: Extract shared utility function to eliminate code duplication Co-authored-by: evilbocchi <71329833+evilbocchi@users.noreply.github.com> --- demo/src/context-test.tsx | 9 +++++++++ transformer/src/analyzer.ts | 11 ++--------- transformer/src/index.ts | 11 ++--------- transformer/src/utils.ts | 18 ++++++++++++++++++ 4 files changed, 31 insertions(+), 18 deletions(-) create mode 100644 demo/src/context-test.tsx create mode 100644 transformer/src/utils.ts 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 dedc58d..56ac8b5 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"]); @@ -499,15 +500,7 @@ export class BlockAnalyzer { // Handle PropertyAccessExpression (e.g., Ctx.Provider, React.Fragment) if (ts.isPropertyAccessExpression(tagName)) { // Return a string representation for ID generation - const getText = (expr: ts.Expression): string => { - if (ts.isIdentifier(expr)) { - return expr.text; - } else if (ts.isPropertyAccessExpression(expr)) { - return getText(expr.expression) + "." + expr.name.text; - } - return "Unknown"; - }; - return getText(tagName.expression) + "." + tagName.name.text; + return jsxTagExpressionToString(tagName.expression) + "." + tagName.name.text; } return "UnknownTag"; diff --git a/transformer/src/index.ts b/transformer/src/index.ts index 97833b8..46ee669 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 @@ -212,15 +213,7 @@ function getTagName(node: ts.JsxElement | ts.JsxSelfClosingElement): string { // Handle PropertyAccessExpression (e.g., Ctx.Provider, React.Fragment) if (ts.isPropertyAccessExpression(tagName)) { // Return a string representation for logging - const getText = (expr: ts.Expression): string => { - if (ts.isIdentifier(expr)) { - return expr.text; - } else if (ts.isPropertyAccessExpression(expr)) { - return getText(expr.expression) + "." + expr.name.text; - } - return "Unknown"; - }; - return getText(tagName.expression) + "." + tagName.name.text; + return jsxTagExpressionToString(tagName.expression) + "." + tagName.name.text; } return "UnknownTag"; diff --git a/transformer/src/utils.ts b/transformer/src/utils.ts new file mode 100644 index 0000000..624d571 --- /dev/null +++ b/transformer/src/utils.ts @@ -0,0 +1,18 @@ +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") + */ +export function jsxTagExpressionToString(expr: ts.Expression): string { + if (ts.isIdentifier(expr)) { + return expr.text; + } else if (ts.isPropertyAccessExpression(expr)) { + return jsxTagExpressionToString(expr.expression) + "." + expr.name.text; + } + return "Unknown"; +} From 170c0ca344e35890516a784cc6d63e3e42a28e8a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Feb 2026 11:18:09 +0000 Subject: [PATCH 5/7] Improve utility function and fix edge cases per code review feedback Co-authored-by: evilbocchi <71329833+evilbocchi@users.noreply.github.com> --- transformer/src/analyzer.ts | 25 +++++++++++-------------- transformer/src/index.ts | 16 +++++----------- transformer/src/utils.ts | 12 ++++++++++-- 3 files changed, 26 insertions(+), 27 deletions(-) diff --git a/transformer/src/analyzer.ts b/transformer/src/analyzer.ts index 56ac8b5..093a043 100644 --- a/transformer/src/analyzer.ts +++ b/transformer/src/analyzer.ts @@ -65,9 +65,12 @@ export class BlockAnalyzer { blockInfo.isStatic = false; // Add the component itself as a dependency // For PropertyAccessExpression like Ctx.Provider, we only need the base identifier (Ctx) - const baseDependency = tagName.split(".")[0]; - if (baseDependency) { - blockInfo.dependencies.push(baseDependency); + // Skip if tagName is UnknownTag or other invalid values + if (tagName !== "UnknownTag" && tagName !== "Unknown") { + const baseDependency = tagName.split(".")[0]; + if (baseDependency) { + blockInfo.dependencies.push(baseDependency); + } } } @@ -493,17 +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; - } - - // Handle PropertyAccessExpression (e.g., Ctx.Provider, React.Fragment) - if (ts.isPropertyAccessExpression(tagName)) { - // Return a string representation for ID generation - return jsxTagExpressionToString(tagName.expression) + "." + tagName.name.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 46ee669..0cb079e 100644 --- a/transformer/src/index.ts +++ b/transformer/src/index.ts @@ -206,17 +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; - } - - // Handle PropertyAccessExpression (e.g., Ctx.Provider, React.Fragment) - if (ts.isPropertyAccessExpression(tagName)) { - // Return a string representation for logging - return jsxTagExpressionToString(tagName.expression) + "." + tagName.name.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/utils.ts b/transformer/src/utils.ts index 624d571..3fd10f2 100644 --- a/transformer/src/utils.ts +++ b/transformer/src/utils.ts @@ -7,12 +7,20 @@ import * as ts from "typescript"; /** * 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.Expression): string { +export function jsxTagExpressionToString(expr: ts.JsxTagNameExpression): string { if (ts.isIdentifier(expr)) { return expr.text; } else if (ts.isPropertyAccessExpression(expr)) { - return jsxTagExpressionToString(expr.expression) + "." + expr.name.text; + const base = jsxTagExpressionToString(expr.expression as ts.JsxTagNameExpression); + return base + "." + 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"; } From f28d351e0e119baa5883b3cd006081d0de2778fa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Feb 2026 11:19:45 +0000 Subject: [PATCH 6/7] Improve code safety and remove unnecessary check Co-authored-by: evilbocchi <71329833+evilbocchi@users.noreply.github.com> --- transformer/src/analyzer.ts | 4 ++-- transformer/src/utils.ts | 22 ++++++++++++++++++++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/transformer/src/analyzer.ts b/transformer/src/analyzer.ts index 093a043..93c931c 100644 --- a/transformer/src/analyzer.ts +++ b/transformer/src/analyzer.ts @@ -65,8 +65,8 @@ export class BlockAnalyzer { blockInfo.isStatic = false; // Add the component itself as a dependency // For PropertyAccessExpression like Ctx.Provider, we only need the base identifier (Ctx) - // Skip if tagName is UnknownTag or other invalid values - if (tagName !== "UnknownTag" && tagName !== "Unknown") { + // Skip if tagName is UnknownTag + if (tagName !== "UnknownTag") { const baseDependency = tagName.split(".")[0]; if (baseDependency) { blockInfo.dependencies.push(baseDependency); diff --git a/transformer/src/utils.ts b/transformer/src/utils.ts index 3fd10f2..cbef2c0 100644 --- a/transformer/src/utils.ts +++ b/transformer/src/utils.ts @@ -13,8 +13,26 @@ export function jsxTagExpressionToString(expr: ts.JsxTagNameExpression): string if (ts.isIdentifier(expr)) { return expr.text; } else if (ts.isPropertyAccessExpression(expr)) { - const base = jsxTagExpressionToString(expr.expression as ts.JsxTagNameExpression); - return base + "." + expr.name.text; + // 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"; From 225e81015c38201140fdf04f7729cc7af6b2c97f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Feb 2026 11:39:15 +0000 Subject: [PATCH 7/7] Add tests for React.Fragment and Provider with dynamic props Co-authored-by: evilbocchi <71329833+evilbocchi@users.noreply.github.com> --- .../test/transformer.integration.test.ts | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/transformer/test/transformer.integration.test.ts b/transformer/test/transformer.integration.test.ts index eaf0218..21daf01 100644 --- a/transformer/test/transformer.integration.test.ts +++ b/transformer/test/transformer.integration.test.ts @@ -250,4 +250,57 @@ export default () => { 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); + }); });