diff --git a/compiler/apps/playground/README.md b/compiler/apps/playground/README.md index 0a2fef224a..7b6d5f729a 100644 --- a/compiler/apps/playground/README.md +++ b/compiler/apps/playground/README.md @@ -26,6 +26,13 @@ $ npm run dev $ yarn ``` +## Testing +```sh +# Install playwright browser binaries +$ npx playwright install --with-deps +# Run tests +$ yarn test +``` ## Deployment This project has been deployed using Vercel. Vercel does the exact same thing as we would diff --git a/compiler/apps/playground/__tests__/e2e/__snapshots__/page.spec.ts/user-input.txt b/compiler/apps/playground/__tests__/e2e/__snapshots__/page.spec.ts/01-user-output.txt similarity index 100% rename from compiler/apps/playground/__tests__/e2e/__snapshots__/page.spec.ts/user-input.txt rename to compiler/apps/playground/__tests__/e2e/__snapshots__/page.spec.ts/01-user-output.txt diff --git a/compiler/apps/playground/__tests__/e2e/__snapshots__/page.spec.ts/default-input.txt b/compiler/apps/playground/__tests__/e2e/__snapshots__/page.spec.ts/02-default-output.txt similarity index 100% rename from compiler/apps/playground/__tests__/e2e/__snapshots__/page.spec.ts/default-input.txt rename to compiler/apps/playground/__tests__/e2e/__snapshots__/page.spec.ts/02-default-output.txt diff --git a/compiler/apps/playground/__tests__/e2e/__snapshots__/page.spec.ts/function-scope-no-directive-arrow-function-expression-output.txt b/compiler/apps/playground/__tests__/e2e/__snapshots__/page.spec.ts/function-scope-no-directive-arrow-function-expression-output.txt new file mode 100644 index 0000000000..db441a034c --- /dev/null +++ b/compiler/apps/playground/__tests__/e2e/__snapshots__/page.spec.ts/function-scope-no-directive-arrow-function-expression-output.txt @@ -0,0 +1,20 @@ +function anonymous_1() { +  "use no memo"; +  const Avatar = () => { +    return 
Avatar Content
; +  }; +  const MemoizedAvatar = React.memo(Avatar); +  const Bio = () => { +    const handleBioUpdate = () => { +      console.log("Bio updated"); +    }; +    return Bio Content; +  }; +  const MemoizedBio = React.memo(Bio); +  return ( +    
+       +       +    
+  ); +} \ No newline at end of file diff --git a/compiler/apps/playground/__tests__/e2e/__snapshots__/page.spec.ts/function-scope-use-memo-arrow-function-expression-output.txt b/compiler/apps/playground/__tests__/e2e/__snapshots__/page.spec.ts/function-scope-use-memo-arrow-function-expression-output.txt new file mode 100644 index 0000000000..974ceeb8b7 --- /dev/null +++ b/compiler/apps/playground/__tests__/e2e/__snapshots__/page.spec.ts/function-scope-use-memo-arrow-function-expression-output.txt @@ -0,0 +1,32 @@ +function anonymous_1() { +  "use memo"; +  const $ = _c(3); +  const Chart = _temp2; +  let t0; +  if ($[0] === Symbol.for("react.memo_cache_sentinel")) { +    t0 = React.memo(Chart); +    $[0] = t0; +  } else { +    t0 = $[0]; +  } +  const MemoizedChart = t0; +  const Graph = _temp3; +  let t1; +  if ($[1] === Symbol.for("react.memo_cache_sentinel")) { +    t1 = React.memo(Graph); +    $[1] = t1; +  } else { +    t1 = $[1]; +  } +  const MemoizedGraph = t1; +  let t2; +  if ($[2] === Symbol.for("react.memo_cache_sentinel")) { +    t2 = ( +      
+         +         +      
+    ); +    $[2] = t2; +  } else { +    t2 = $[2]; \ No newline at end of file diff --git a/compiler/apps/playground/__tests__/e2e/__snapshots__/page.spec.ts/function-scope-use-memo-function-declaration-output.txt b/compiler/apps/playground/__tests__/e2e/__snapshots__/page.spec.ts/function-scope-use-memo-function-declaration-output.txt new file mode 100644 index 0000000000..3caacf369c --- /dev/null +++ b/compiler/apps/playground/__tests__/e2e/__snapshots__/page.spec.ts/function-scope-use-memo-function-declaration-output.txt @@ -0,0 +1,32 @@ +function App() { +  "use memo"; +  const $ = _c(3); +  let t0; +  if ($[0] === Symbol.for("react.memo_cache_sentinel")) { +    const Sidebar = function Sidebar() { +      const handleToggle = _temp; +      return Sidebar Content; +    }; +    t0 = React.memo(Sidebar); +    $[0] = t0; +  } else { +    t0 = $[0]; +  } +  const MemoizedSidebar = t0; +  let t1; +  if ($[1] === Symbol.for("react.memo_cache_sentinel")) { +    const Content = function Content() { +      return 
Main Content
; +    }; +    t1 = React.memo(Content); +    $[1] = t1; +  } else { +    t1 = $[1]; +  } +  const MemoizedContent = t1; +  let t2; +  if ($[2] === Symbol.for("react.memo_cache_sentinel")) { +    t2 = ( +      
+         \ No newline at end of file diff --git a/compiler/apps/playground/__tests__/e2e/__snapshots__/page.spec.ts/function-scope-use-no-memo-function-declaration-output.txt b/compiler/apps/playground/__tests__/e2e/__snapshots__/page.spec.ts/function-scope-use-no-memo-function-declaration-output.txt new file mode 100644 index 0000000000..51fb860de2 --- /dev/null +++ b/compiler/apps/playground/__tests__/e2e/__snapshots__/page.spec.ts/function-scope-use-no-memo-function-declaration-output.txt @@ -0,0 +1,32 @@ +function Settings() { +  "use memo"; +  const $ = _c(3); +  let t0; +  if ($[0] === Symbol.for("react.memo_cache_sentinel")) { +    t0 = function Preferences() { +      const handleSave = _temp; +      return Preferences Content; +    }; +    $[0] = t0; +  } else { +    t0 = $[0]; +  } +  const Preferences = t0; +  let t1; +  if ($[1] === Symbol.for("react.memo_cache_sentinel")) { +    t1 = function Notifications() { +      return 
Notifications Settings
; +    }; +    $[1] = t1; +  } else { +    t1 = $[1]; +  } +  const Notifications = t1; +  let t2; +  if ($[2] === Symbol.for("react.memo_cache_sentinel")) { +    t2 = ( +      
+         +         +      
\ No newline at end of file diff --git a/compiler/apps/playground/__tests__/e2e/__snapshots__/page.spec.ts/function-scope-use-no-memo-function-expression-output.txt b/compiler/apps/playground/__tests__/e2e/__snapshots__/page.spec.ts/function-scope-use-no-memo-function-expression-output.txt new file mode 100644 index 0000000000..2c31ad8e50 --- /dev/null +++ b/compiler/apps/playground/__tests__/e2e/__snapshots__/page.spec.ts/function-scope-use-no-memo-function-expression-output.txt @@ -0,0 +1,18 @@ +function anonymous_1() { +  "use no memo"; +  const Widget = function () { +    const handleExpand = () => { +      console.log("Widget expanded"); +    }; +    return Widget Content
; +  }; +  const Panel = function () { +    return 
Panel Information
; +  }; +  return ( +    
+       +       +    
+  ); +} \ No newline at end of file diff --git a/compiler/apps/playground/__tests__/e2e/__snapshots__/page.spec.ts/module-scope-use-memo-output.txt b/compiler/apps/playground/__tests__/e2e/__snapshots__/page.spec.ts/module-scope-use-memo-output.txt new file mode 100644 index 0000000000..3b26c022db --- /dev/null +++ b/compiler/apps/playground/__tests__/e2e/__snapshots__/page.spec.ts/module-scope-use-memo-output.txt @@ -0,0 +1,15 @@ +function anonymous_1() { +  const $ = _c(1); +  const handleClick = _temp; +  let t0; +  if ($[0] === Symbol.for("react.memo_cache_sentinel")) { +    t0 = Welcome to the App!; +    $[0] = t0; +  } else { +    t0 = $[0]; +  } +  return t0; +} +function _temp() { +  console.log("Header clicked"); +} \ No newline at end of file diff --git a/compiler/apps/playground/__tests__/e2e/__snapshots__/page.spec.ts/module-scope-use-no-memo-function-expression-output.txt b/compiler/apps/playground/__tests__/e2e/__snapshots__/page.spec.ts/module-scope-use-no-memo-function-expression-output.txt new file mode 100644 index 0000000000..7af2442b5c --- /dev/null +++ b/compiler/apps/playground/__tests__/e2e/__snapshots__/page.spec.ts/module-scope-use-no-memo-function-expression-output.txt @@ -0,0 +1,3 @@ +function anonymous_1() { +  return ; +} \ No newline at end of file diff --git a/compiler/apps/playground/__tests__/e2e/__snapshots__/page.spec.ts/module-scope-use-no-memo-output.txt b/compiler/apps/playground/__tests__/e2e/__snapshots__/page.spec.ts/module-scope-use-no-memo-output.txt new file mode 100644 index 0000000000..3433dfeaae --- /dev/null +++ b/compiler/apps/playground/__tests__/e2e/__snapshots__/page.spec.ts/module-scope-use-no-memo-output.txt @@ -0,0 +1,7 @@ +function anonymous_1() { +  const handleMouseOver = () => { +    console.log("Footer hovered"); +  }; +  return Footer  +      Information; +} \ No newline at end of file diff --git a/compiler/apps/playground/__tests__/e2e/page.spec.ts b/compiler/apps/playground/__tests__/e2e/page.spec.ts index bc6083f52d..1c2b2317d5 100644 --- a/compiler/apps/playground/__tests__/e2e/page.spec.ts +++ b/compiler/apps/playground/__tests__/e2e/page.spec.ts @@ -8,42 +8,245 @@ import {expect, test} from '@playwright/test'; import {encodeStore, type Store} from '../../lib/stores'; -const STORE: Store = { - source: `export default function TestComponent({ x }) { - return ; -} -`, -}; -const HASH = encodeStore(STORE); +test.describe.configure({mode: 'parallel'}); function concat(data: Array): string { return data.join(''); } +const DIRECTIVE_TEST_CASES = [ + { + name: 'module-scope-use-memo', + input: `'use memo'; -test('editor should compile successfully', async ({page}) => { - await page.goto(`/#${HASH}`, {waitUntil: 'networkidle'}); +const Header = () => { + const handleClick = () => { + console.log('Header clicked'); + }; + + return

Welcome to the App!

; +};`, + }, + { + name: 'module-scope-use-no-memo', + input: `'use no memo'; + +const Footer = () => { + const handleMouseOver = () => { + console.log('Footer hovered'); + }; + + return
Footer Information
; +}; +`, + }, + { + name: 'function-scope-use-memo-function-declaration', + input: `function App() { + 'use memo'; + + function Sidebar() { + const handleToggle = () => { + console.log('Sidebar toggled'); + }; + + return ; + } + + const MemoizedSidebar = React.memo(Sidebar); + + function Content() { + return
Main Content
; + } + + const MemoizedContent = React.memo(Content); + + return ( +
+ + +
+ ); +}`, + }, + { + name: 'function-scope-use-no-memo-function-expression', + input: `const Dashboard = function() { + 'use no memo'; + const Widget = function() { + const handleExpand = () => { + console.log('Widget expanded'); + }; + + return
Widget Content
; + }; + + const Panel = function() { + return
Panel Information
; + }; + + return ( +
+ + +
+ ); +};`, + }, + { + name: 'function-scope-use-memo-arrow-function-expression', + input: `const Analytics = () => { + 'use memo'; + + const Chart = () => { + const handleRefresh = () => { + console.log('Chart refreshed'); + }; + + return
Chart Content
; + }; + + const MemoizedChart = React.memo(Chart); + + const Graph = () => { + return
Graph Content
; + }; + + const MemoizedGraph = React.memo(Graph); + + return ( +
+ + +
+ ); +};`, + }, + { + name: 'module-scope-use-no-memo-function-expression', + input: `'use no memo'; + +const Sidebar = function() { + return ; +};`, + }, + { + name: 'function-scope-no-directive-arrow-function-expression', + input: ` +const Profile = () => { +'use no memo'; + const Avatar = () => { + return
Avatar Content
; + }; + + const MemoizedAvatar = React.memo(Avatar); + + const Bio = () => { + const handleBioUpdate = () => { + console.log('Bio updated'); + }; + + return
Bio Content
; + }; + + const MemoizedBio = React.memo(Bio); + + return ( +
+ + +
+ ); +};`, + }, + { + name: 'function-scope-use-no-memo-function-declaration', + input: `'use no memo'; + +function Settings() { + 'use memo'; + + function Preferences() { + const handleSave = () => { + console.log('Preferences saved'); + }; + + return
Preferences Content
; + } + + function Notifications() { + return
Notifications Settings
; + } + + return ( +
+ + +
+ ); +}`, + }, +]; +test('editor should open successfully', async ({page}) => { + await page.goto(`/`, {waitUntil: 'networkidle'}); await page.screenshot({ fullPage: true, - path: 'test-results/00-on-networkidle.png', + path: 'test-results/00-fresh-page.png', }); +}); +test('editor should compile from hash successfully', async ({page}) => { + const store: Store = { + source: `export default function TestComponent({ x }) { + return ; + } + `, + }; + const hash = encodeStore(store); + await page.goto(`/#${hash}`, {waitUntil: 'networkidle'}); // User input from hash compiles await page.screenshot({ fullPage: true, - path: 'test-results/01-show-js-before.png', + path: 'test-results/01-compiles-from-hash.png', }); const userInput = (await page.locator('.monaco-editor').nth(1).allInnerTexts()) ?? []; - expect(concat(userInput)).toMatchSnapshot('user-input.txt'); + expect(concat(userInput)).toMatchSnapshot('01-user-output.txt'); +}); +test('reset button works', async ({page}) => { + const store: Store = { + source: `export default function TestComponent({ x }) { + return ; + } + `, + }; + const hash = encodeStore(store); + await page.goto(`/#${hash}`, {waitUntil: 'networkidle'}); // Reset button works page.on('dialog', dialog => dialog.accept()); await page.getByRole('button', {name: 'Reset'}).click(); await page.screenshot({ fullPage: true, - path: 'test-results/02-show-js-after.png', + path: 'test-results/02-reset-button-works.png', }); const defaultInput = (await page.locator('.monaco-editor').nth(1).allInnerTexts()) ?? []; - expect(concat(defaultInput)).toMatchSnapshot('default-input.txt'); + expect(concat(defaultInput)).toMatchSnapshot('02-default-output.txt'); }); +DIRECTIVE_TEST_CASES.forEach((t, idx) => + test(`directives work: ${t.name}`, async ({page}) => { + const store: Store = { + source: t.input, + }; + const hash = encodeStore(store); + await page.goto(`/#${hash}`, {waitUntil: 'networkidle'}); + await page.screenshot({ + fullPage: true, + path: `test-results/03-0${idx}-${t.name}.png`, + }); + + const useMemoOutput = + (await page.locator('.monaco-editor').nth(1).allInnerTexts()) ?? []; + expect(concat(useMemoOutput)).toMatchSnapshot(`${t.name}-output.txt`); + }), +); diff --git a/compiler/apps/playground/components/Editor/EditorImpl.tsx b/compiler/apps/playground/components/Editor/EditorImpl.tsx index 1bdd372ad2..82a40272bd 100644 --- a/compiler/apps/playground/components/Editor/EditorImpl.tsx +++ b/compiler/apps/playground/components/Editor/EditorImpl.tsx @@ -18,6 +18,8 @@ import { ValueKind, runPlayground, type Hook, + findDirectiveDisablingMemoization, + findDirectiveEnablingMemoization, } from 'babel-plugin-react-compiler/src'; import {type ReactFunctionType} from 'babel-plugin-react-compiler/src/HIR/Environment'; import clsx from 'clsx'; @@ -43,6 +45,25 @@ import { import {printFunctionWithOutlined} from 'babel-plugin-react-compiler/src/HIR/PrintHIR'; import {printReactiveFunctionWithOutlined} from 'babel-plugin-react-compiler/src/ReactiveScopes/PrintReactiveFunction'; +type FunctionLike = + | NodePath + | NodePath + | NodePath; +enum MemoizeDirectiveState { + Enabled = 'Enabled', + Disabled = 'Disabled', + Undefined = 'Undefined', +} + +const MEMOIZE_ENABLED_OR_UNDEFINED_STATES = new Set([ + MemoizeDirectiveState.Enabled, + MemoizeDirectiveState.Undefined, +]); + +const MEMOIZE_ENABLED_OR_DISABLED_STATES = new Set([ + MemoizeDirectiveState.Enabled, + MemoizeDirectiveState.Disabled, +]); function parseInput(input: string, language: 'flow' | 'typescript'): any { // Extract the first line to quickly check for custom test directives if (language === 'flow') { @@ -63,29 +84,36 @@ function parseInput(input: string, language: 'flow' | 'typescript'): any { function parseFunctions( source: string, language: 'flow' | 'typescript', -): Array< - | NodePath - | NodePath - | NodePath -> { - const items: Array< - | NodePath - | NodePath - | NodePath - > = []; +): Array<{ + compilationEnabled: boolean; + fn: FunctionLike; +}> { + const items: Array<{ + compilationEnabled: boolean; + fn: FunctionLike; + }> = []; try { const ast = parseInput(source, language); traverse(ast, { FunctionDeclaration(nodePath) { - items.push(nodePath); + items.push({ + compilationEnabled: shouldCompile(nodePath), + fn: nodePath, + }); nodePath.skip(); }, ArrowFunctionExpression(nodePath) { - items.push(nodePath); + items.push({ + compilationEnabled: shouldCompile(nodePath), + fn: nodePath, + }); nodePath.skip(); }, FunctionExpression(nodePath) { - items.push(nodePath); + items.push({ + compilationEnabled: shouldCompile(nodePath), + fn: nodePath, + }); nodePath.skip(); }, }); @@ -98,9 +126,48 @@ function parseFunctions( suggestions: null, }); } + return items; } +function shouldCompile(fn: FunctionLike): boolean { + const {body} = fn.node; + if (t.isBlockStatement(body)) { + const selfCheck = checkExplicitMemoizeDirectives(body.directives); + if (selfCheck === MemoizeDirectiveState.Enabled) return true; + if (selfCheck === MemoizeDirectiveState.Disabled) return false; + + const parentWithDirective = fn.findParent(parentPath => { + if (parentPath.isBlockStatement() || parentPath.isProgram()) { + const directiveCheck = checkExplicitMemoizeDirectives( + parentPath.node.directives, + ); + return MEMOIZE_ENABLED_OR_DISABLED_STATES.has(directiveCheck); + } + return false; + }); + + if (!parentWithDirective) return true; + const parentDirectiveCheck = checkExplicitMemoizeDirectives( + (parentWithDirective.node as t.Program | t.BlockStatement).directives, + ); + return MEMOIZE_ENABLED_OR_UNDEFINED_STATES.has(parentDirectiveCheck); + } + return false; +} + +function checkExplicitMemoizeDirectives( + directives: Array, +): MemoizeDirectiveState { + if (findDirectiveEnablingMemoization(directives).length) { + return MemoizeDirectiveState.Enabled; + } + if (findDirectiveDisablingMemoization(directives).length) { + return MemoizeDirectiveState.Disabled; + } + return MemoizeDirectiveState.Undefined; +} + const COMMON_HOOKS: Array<[string, Hook]> = [ [ 'useFragment', @@ -209,18 +276,38 @@ function compile(source: string): [CompilerOutput, 'flow' | 'typescript'] { // Extract the first line to quickly check for custom test directives const pragma = source.substring(0, source.indexOf('\n')); const config = parseConfigPragmaForTests(pragma); - - for (const fn of parseFunctions(source, language)) { - const id = withIdentifier(getFunctionIdentifier(fn)); + const parsedFunctions = parseFunctions(source, language); + for (const func of parsedFunctions) { + const id = withIdentifier(getFunctionIdentifier(func.fn)); + const fnName = id.name; + if (!func.compilationEnabled) { + upsert({ + kind: 'ast', + fnName, + name: 'CodeGen', + value: { + type: 'FunctionDeclaration', + id: + func.fn.isArrowFunctionExpression() || + func.fn.isFunctionExpression() + ? withIdentifier(null) + : func.fn.node.id, + async: func.fn.node.async, + generator: !!func.fn.node.generator, + body: func.fn.node.body as t.BlockStatement, + params: func.fn.node.params, + }, + }); + continue; + } for (const result of runPlayground( - fn, + func.fn, { ...config, customHooks: new Map([...COMMON_HOOKS]), }, getReactFunctionType(id), )) { - const fnName = id.name; switch (result.kind) { case 'ast': { upsert({ diff --git a/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Program.ts b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Program.ts index 1b1a82db0b..f5dbb324bf 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Program.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Program.ts @@ -42,10 +42,10 @@ export type CompilerPass = { comments: Array; code: string | null; }; -const OPT_IN_DIRECTIVES = new Set(['use forget', 'use memo']); +export const OPT_IN_DIRECTIVES = new Set(['use forget', 'use memo']); export const OPT_OUT_DIRECTIVES = new Set(['use no forget', 'use no memo']); -function findDirectiveEnablingMemoization( +export function findDirectiveEnablingMemoization( directives: Array, ): Array { return directives.filter(directive => @@ -53,7 +53,7 @@ function findDirectiveEnablingMemoization( ); } -function findDirectiveDisablingMemoization( +export function findDirectiveDisablingMemoization( directives: Array, ): Array { return directives.filter(directive => diff --git a/compiler/packages/babel-plugin-react-compiler/src/index.ts b/compiler/packages/babel-plugin-react-compiler/src/index.ts index 60a7e7843c..150df26e45 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/index.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/index.ts @@ -20,6 +20,9 @@ export { run, runPlayground, OPT_OUT_DIRECTIVES, + OPT_IN_DIRECTIVES, + findDirectiveEnablingMemoization, + findDirectiveDisablingMemoization, type CompilerPipelineValue, type PluginOptions, } from './Entrypoint';