Skip to content
Merged
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
9 changes: 9 additions & 0 deletions demo/src/context-test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import React, { createContext, useContext } from "@rbxts/react";

const layerContext = createContext(0);

export default () => {
const depth = useContext(layerContext);

return <layerContext.Provider value={depth + 1}></layerContext.Provider>;
};
22 changes: 15 additions & 7 deletions transformer/src/analyzer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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";
}

/**
Expand Down
11 changes: 6 additions & 5 deletions transformer/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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";
}

/**
Expand Down
52 changes: 41 additions & 11 deletions transformer/src/transformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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., <ns:tag>) - 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(
Expand Down Expand Up @@ -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(
Expand All @@ -374,7 +398,7 @@ function generateFinePatchBlock(
),
undefined,
[
createTagReference(tagName),
createTagReference(tagExpression),
allProps.length > 0 ? createPropsObject(allProps) : ts.factory.createIdentifier("undefined"),
...children,
],
Expand Down Expand Up @@ -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(
Expand All @@ -634,7 +661,7 @@ function generateMemoizedBlock(
),
undefined,
[
createTagReference(tagName),
createTagReference(tagExpression),
allProps.length > 0 ? createPropsObject(allProps) : ts.factory.createIdentifier("undefined"),
...children,
],
Expand Down Expand Up @@ -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 {
Expand Down
44 changes: 44 additions & 0 deletions transformer/src/utils.ts
Original file line number Diff line number Diff line change
@@ -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> -> "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., <ns:tag>)
return expr.namespace.text + ":" + expr.name.text;
}
return "Unknown";
}
75 changes: 75 additions & 0 deletions transformer/test/transformer.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <layerContext.Provider value={depth + 1}></layerContext.Provider>;
};
`;
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 (
<React.Fragment>
{items.map((item) => (
<textlabel key={item} Text={item} />
))}
</React.Fragment>
);
}
`;
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 <ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>;
}
`;
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);
});
});