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);
+ });
});