Skip to content

Commit

Permalink
Handle functions like forwardRef. (#5846)
Browse files Browse the repository at this point in the history
- `createComponentRendererComponent` now creates a regular function
instead of an arrow function and that function analyses the `arguments`
global to determine what the actual arguments to the function are. Then
applies some light heuristics to then fix the "props" to one of those
arguments.
- `applyPropsParamToPassedProps` reworked to handle the multiple
function arguments.
- `propsUsedByIdentifierOrAccess` now caters for multiple function
parameters.
- `codeUsesProperty` and associated functions now handle multiple
function parameters.
- `UtopiaJSXComponent.param` is now `UtopiaJSXComponent.params`.
- `parseCode` now parses multiple parameters for a given function.
  • Loading branch information
seanparsons authored Jun 6, 2024
1 parent 826be03 commit 94965aa
Show file tree
Hide file tree
Showing 22 changed files with 882 additions and 1,837 deletions.
1,326 changes: 0 additions & 1,326 deletions editor/src/components/canvas/__snapshots__/ui-jsx-canvas.spec.tsx.snap

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { MapLike } from 'typescript'
import type {
EarlyReturn,
JSXElementChild,
Param,
UtopiaJSXComponent,
} from '../../../core/shared/element-template'
import {
Expand Down Expand Up @@ -69,12 +70,32 @@ export function createComponentRendererComponent(params: {
filePath: string
mutableContextRef: React.MutableRefObject<MutableUtopiaCtxRefData>
}): ComponentRendererComponent {
const Component = (realPassedPropsIncludingUtopiaSpecialStuff: any) => {
const Component = (...functionArguments: Array<any>) => {
// Attempt to determine which function argument is the "regular" props object/value.
// Default it to the first if one is not identified by looking for some of our special keys.
let regularPropsArgumentIndex: number = functionArguments.findIndex((functionArgument) => {
if (
typeof functionArgument === 'object' &&
functionArgument != null &&
!Array.isArray(functionArgument)
) {
return UTOPIA_INSTANCE_PATH in functionArgument || UTOPIA_PATH_KEY in functionArgument
} else {
return false
}
})
if (regularPropsArgumentIndex < 0) {
regularPropsArgumentIndex = 0
}
const {
[UTOPIA_INSTANCE_PATH]: instancePathAny, // TODO types?
[UTOPIA_PATH_KEY]: pathsString, // TODO types?
...realPassedProps
} = realPassedPropsIncludingUtopiaSpecialStuff
} = functionArguments[regularPropsArgumentIndex]

// We want to strip the instance path and path from the props that we pass to the component.
let slightlyStrippedFunctionsArguments = [...functionArguments]
slightlyStrippedFunctionsArguments[regularPropsArgumentIndex] = realPassedProps

const mutableContext = params.mutableContextRef.current[params.filePath].mutableContext

Expand Down Expand Up @@ -140,7 +161,7 @@ export function createComponentRendererComponent(params: {
applyPropsParamToPassedProps(
mutableContext.rootScope,
rootElementPath,
realPassedProps,
slightlyStrippedFunctionsArguments,
param,
{
requireResult: mutableContext.requireResult,
Expand All @@ -166,7 +187,7 @@ export function createComponentRendererComponent(params: {
undefined,
codeError,
),
utopiaJsxComponent.param,
utopiaJsxComponent.params,
) ?? { props: realPassedProps }

let scope: MapLike<any> = {
Expand All @@ -177,13 +198,15 @@ export function createComponentRendererComponent(params: {
let spiedVariablesInScope: VariableData = {
...mutableContext.spiedVariablesDeclaredInRootScope,
}
if (rootElementPath != null && utopiaJsxComponent.param != null) {
propertiesExposedByParam(utopiaJsxComponent.param).forEach((paramName) => {
spiedVariablesInScope[paramName] = {
spiedValue: scope[paramName],
insertionCeiling: rootElementPath,
}
})
if (rootElementPath != null && utopiaJsxComponent.params != null) {
for (const param of utopiaJsxComponent.params) {
propertiesExposedByParam(param).forEach((paramName) => {
spiedVariablesInScope[paramName] = {
spiedValue: scope[paramName],
insertionCeiling: rootElementPath,
}
})
}
}

// Protect against infinite recursion by taking the view that anything
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,5 @@
import type { MapLike } from 'typescript'
import type {
Param,
BoundParam,
JSExpressionMapOrOtherJavascript,
JSExpression,
} from '../../../core/shared/element-template'
import type { Param, BoundParam, JSExpression } from '../../../core/shared/element-template'
import {
isRegularParam,
isDestructuredObject,
Expand All @@ -17,8 +12,8 @@ import type { RenderContext } from './ui-jsx-canvas-element-renderer-utils'
export function applyPropsParamToPassedProps(
inScope: MapLike<any>,
elementPath: ElementPath | null,
passedProps: MapLike<unknown>,
propsParam: Param,
functionArguments: Array<any>,
propsParams: Array<Param>,
renderContext: RenderContext,
uid: string | undefined,
codeError: Error | null,
Expand All @@ -45,14 +40,18 @@ export function applyPropsParamToPassedProps(
}
}

function applyBoundParamToOutput(value: unknown, boundParam: BoundParam): void {
function applyBoundParamToOutput(functionArgument: unknown, boundParam: BoundParam): void {
if (isRegularParam(boundParam)) {
const { paramName } = boundParam
output[paramName] = getParamValue(paramName, value, boundParam.defaultExpression)
output[paramName] = getParamValue(paramName, functionArgument, boundParam.defaultExpression)
} else if (isDestructuredObject(boundParam)) {
if (typeof value === 'object' && !Array.isArray(value) && value !== null) {
const valueAsRecord: Record<string, unknown> = { ...value }
let remainingValues = { ...value } as Record<string, unknown>
if (
typeof functionArgument === 'object' &&
!Array.isArray(functionArgument) &&
functionArgument !== null
) {
const valueAsRecord: Record<string, unknown> = { ...functionArgument }
let remainingValues = { ...functionArgument } as Record<string, unknown>
for (const part of boundParam.parts) {
const { propertyName, param } = part
if (propertyName == null) {
Expand Down Expand Up @@ -85,8 +84,8 @@ export function applyPropsParamToPassedProps(
}
// TODO Throw, but what?
} else {
if (Array.isArray(value)) {
let remainingValues = [...value]
if (Array.isArray(functionArgument)) {
let remainingValues = [...functionArgument]
boundParam.parts.forEach((param) => {
if (isOmittedParam(param)) {
remainingValues.shift()
Expand Down Expand Up @@ -115,6 +114,8 @@ export function applyPropsParamToPassedProps(
}
}

applyBoundParamToOutput(passedProps, propsParam.boundParam)
propsParams.forEach((propsParam, paramIndex) => {
applyBoundParamToOutput(functionArguments[paramIndex], propsParam.boundParam)
})
return output
}
96 changes: 93 additions & 3 deletions editor/src/components/canvas/ui-jsx-canvas.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/* eslint jest/expect-expect: ["error", { "assertFunctionNames": ["expect", "testCanvasRender", "testCanvasRenderMultifile", "testCanvasErrorMultifile"] }] */
/* eslint jest/expect-expect: ["error", { "assertFunctionNames": ["expect", "testCanvasRender", "testCanvasRenderMultifile", "testCanvasErrorMultifile", "testCanvasRenderInline"] }] */
import { BakedInStoryboardUID, BakedInStoryboardVariableName } from '../../core/model/scene-utils'
import { AwkwardFragmentsCode } from '../../core/workers/parser-printer/parser-printer-fragments.test-utils'
import {
Expand Down Expand Up @@ -157,7 +157,7 @@ export { ToBeDefaultExported as default }`,
})

it('renders a canvas with a component wrapped in React.memo', () => {
testCanvasRender(
testCanvasRenderInline(
null,
`
import * as React from 'react'
Expand Down Expand Up @@ -194,7 +194,7 @@ export { ToBeDefaultExported as default }`,
})

it('renders a canvas with a component wrapped in a function that wraps the component with more content', () => {
testCanvasRender(
testCanvasRenderInline(
null,
`
import * as React from 'react'
Expand Down Expand Up @@ -241,6 +241,96 @@ export { ToBeDefaultExported as default }`,
)
})

it('renders a canvas with a component wrapped in a function that has more that one parameter', () => {
testCanvasRenderInline(
null,
`
import * as React from 'react'
import { Storyboard, Scene } from 'utopia-api'
function twoParameterWrapper(componentFunction) {
return (props) => {
return componentFunction(props, 'Some Text')
}
}
export var App = twoParameterWrapper((props, otherValue) => {
return (
<div
style={{ backgroundColor: props.backgroundColor }}
data-uid={'aaa'}
>
<span style={{position: 'absolute'}} data-uid={'bbb'}>{otherValue}</span>
</div>
)
})
export var ${BakedInStoryboardVariableName} = (props) => {
return (
<Storyboard data-uid={'${BakedInStoryboardUID}'}>
<Scene
style={{ position: 'absolute', height: 200, left: 59, width: 200, top: 79 }}
data-uid={'${TestSceneUID}'}
>
<App
data-uid='${TestAppUID}'
style={{ position: 'absolute', height: '100%', width: '100%' }}
backgroundColor='green'
/>
</Scene>
</Storyboard>
)
}
`,
)
})

it('renders a canvas with a component wrapped in forwardRef', () => {
testCanvasRenderInline(
null,
`
import * as React from 'react'
import { Storyboard, Scene } from 'utopia-api'
export var ComponentThatForwards = React.forwardRef((props, ref) => {
return (
<div
style={{ backgroundColor: props.backgroundColor }}
data-uid={'aaa'}
ref={ref}
>
<span style={{position: 'absolute'}} data-uid={'bbb'}>Something</span>
</div>
)
})
export var App = () => {
const ref = React.useRef(null)
return (
<ComponentThatForwards ref={ref} />
)
}
export var ${BakedInStoryboardVariableName} = (props) => {
return (
<Storyboard data-uid={'${BakedInStoryboardUID}'}>
<Scene
style={{ position: 'absolute', height: 200, left: 59, width: 200, top: 79 }}
data-uid={'${TestSceneUID}'}
>
<App
data-uid='${TestAppUID}'
style={{ position: 'absolute', height: '100%', width: '100%' }}
backgroundColor='green'
/>
</Scene>
</Storyboard>
)
}
`,
)
})

it('renders a canvas defined by a utopia storyboard component', () => {
testCanvasRender(
null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -789,15 +789,17 @@ describe('UtopiaJSXComponentKeepDeepEquality', () => {
declarationSyntax: 'const',
blockOrExpression: 'block',
functionWrapping: [],
param: {
type: 'PARAM',
dotDotDotToken: false,
boundParam: {
type: 'REGULAR_PARAM',
paramName: 'props',
defaultExpression: null,
params: [
{
type: 'PARAM',
dotDotDotToken: false,
boundParam: {
type: 'REGULAR_PARAM',
paramName: 'props',
defaultExpression: null,
},
},
},
],
propsUsed: [],
rootElement: {
type: 'JSX_ELEMENT',
Expand Down Expand Up @@ -839,15 +841,17 @@ describe('UtopiaJSXComponentKeepDeepEquality', () => {
declarationSyntax: 'const',
blockOrExpression: 'block',
functionWrapping: [],
param: {
type: 'PARAM',
dotDotDotToken: false,
boundParam: {
type: 'REGULAR_PARAM',
paramName: 'props',
defaultExpression: null,
params: [
{
type: 'PARAM',
dotDotDotToken: false,
boundParam: {
type: 'REGULAR_PARAM',
paramName: 'props',
defaultExpression: null,
},
},
},
],
propsUsed: [],
rootElement: {
type: 'JSX_ELEMENT',
Expand Down Expand Up @@ -889,15 +893,17 @@ describe('UtopiaJSXComponentKeepDeepEquality', () => {
declarationSyntax: 'const',
blockOrExpression: 'block',
functionWrapping: [],
param: {
type: 'PARAM',
dotDotDotToken: false,
boundParam: {
type: 'REGULAR_PARAM',
paramName: 'props',
defaultExpression: null,
params: [
{
type: 'PARAM',
dotDotDotToken: false,
boundParam: {
type: 'REGULAR_PARAM',
paramName: 'props',
defaultExpression: null,
},
},
},
],
propsUsed: [],
rootElement: {
type: 'JSX_ELEMENT',
Expand Down Expand Up @@ -950,7 +956,7 @@ describe('UtopiaJSXComponentKeepDeepEquality', () => {
expect(result.value.isFunction).toBe(oldValue.isFunction)
expect(result.value.declarationSyntax).toBe(oldValue.declarationSyntax)
expect(result.value.blockOrExpression).toBe(oldValue.blockOrExpression)
expect(result.value.param).toBe(oldValue.param)
expect(result.value.params).toBe(oldValue.params)
expect(result.value.propsUsed).toBe(oldValue.propsUsed)
expect(result.value.rootElement).toBe(oldValue.rootElement)
expect(result.value.arbitraryJSBlock).toBe(oldValue.arbitraryJSBlock)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1605,8 +1605,8 @@ export const UtopiaJSXComponentKeepDeepEquality: KeepDeepEqualityCall<UtopiaJSXC
createCallWithTripleEquals(),
(component) => component.functionWrapping,
arrayDeepEquality(FunctionWrapKeepDeepEquality),
(component) => component.param,
nullableDeepEquality(ParamKeepDeepEquality()),
(component) => component.params,
nullableDeepEquality(arrayDeepEquality(ParamKeepDeepEquality())),
(component) => component.propsUsed,
arrayDeepEquality(createCallWithTripleEquals()),
(component) => component.rootElement,
Expand Down
4 changes: 2 additions & 2 deletions editor/src/components/shared/project-components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -686,8 +686,8 @@ function isUtopiaJSXComponentEligibleForMode(
if (insertMenuMode === 'wrap') {
return (
element != null &&
element.param != null &&
elementUsesProperty(element.rootElement, element.param, 'children')
element.params != null &&
elementUsesProperty(element.rootElement, element.params, 'children')
)
}
return true
Expand Down
9 changes: 3 additions & 6 deletions editor/src/core/data-tracing/data-tracing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -756,13 +756,10 @@ function lookupInComponentScope(

// let's see if the identifier points to a component prop
{
if (componentHoldingElement.param != null) {
const firstParam = componentHoldingElement?.params?.at(0)
if (firstParam != null) {
// let's try to match the name to the containing component's props!
const foundPropSameName = propUsedByIdentifierOrAccess(
componentHoldingElement.param,
identifier,
pathDrillSoFar,
)
const foundPropSameName = propUsedByIdentifierOrAccess(firstParam, identifier, pathDrillSoFar)

if (isRight(foundPropSameName)) {
// ok, so let's now travel to the containing component's instance in the metadata and continue the lookup!
Expand Down
Loading

0 comments on commit 94965aa

Please sign in to comment.