Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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";
}
22 changes: 22 additions & 0 deletions transformer/test/transformer.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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");
});
});
Loading