Skip to content

Commit d8fe224

Browse files
developitTimer
authored andcommitted
Hook destructuring optimization (#8381)
* Add hook destructuring optimization * oops, accidentally included loose mode * inline hook destructuring optimization plugin * fix test nesting * fix lockfile * allow any react hook * Add page to stats-app with hooks
1 parent cfd6080 commit d8fe224

File tree

4 files changed

+180
-60
lines changed

4 files changed

+180
-60
lines changed
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { PluginObj } from '@babel/core'
2+
import { NodePath } from '@babel/traverse'
3+
import * as BabelTypes from '@babel/types'
4+
5+
// matches any hook-like (the default)
6+
const isHook = /^use[A-Z]/
7+
8+
// matches only built-in hooks provided by React et al
9+
const isBuiltInHook = /^use(Callback|Context|DebugValue|Effect|ImperativeHandle|LayoutEffect|Memo|Reducer|Ref|State)$/
10+
11+
export default function({
12+
types: t,
13+
}: {
14+
types: typeof BabelTypes
15+
}): PluginObj<any> {
16+
const visitor = {
17+
CallExpression(path: NodePath<BabelTypes.CallExpression>, state: any) {
18+
const onlyBuiltIns = state.opts.onlyBuiltIns
19+
20+
// if specified, options.lib is a list of libraries that provide hook functions
21+
const libs =
22+
state.opts.lib &&
23+
(state.opts.lib === true
24+
? ['react', 'preact/hooks']
25+
: [].concat(state.opts.lib))
26+
27+
// skip function calls that are not the init of a variable declaration:
28+
if (!t.isVariableDeclarator(path.parent)) return
29+
30+
// skip function calls where the return value is not Array-destructured:
31+
if (!t.isArrayPattern(path.parent.id)) return
32+
33+
// name of the (hook) function being called:
34+
const hookName = (path.node.callee as BabelTypes.Identifier).name
35+
36+
if (libs) {
37+
const binding = path.scope.getBinding(hookName)
38+
// not an import
39+
if (!binding || binding.kind !== 'module') return
40+
41+
const specifier = (binding.path.parent as BabelTypes.ImportDeclaration)
42+
.source.value
43+
// not a match
44+
if (!libs.some(lib => lib === specifier)) return
45+
}
46+
47+
// only match function calls with names that look like a hook
48+
if (!(onlyBuiltIns ? isBuiltInHook : isHook).test(hookName)) return
49+
50+
path.parent.id = t.objectPattern(
51+
path.parent.id.elements.map((element, i) =>
52+
t.objectProperty(t.numericLiteral(i), element)
53+
)
54+
)
55+
},
56+
}
57+
58+
return {
59+
name: 'optimize-hook-destructuring',
60+
visitor: {
61+
// this is a workaround to run before preset-env destroys destructured assignments
62+
Program(path, state) {
63+
path.traverse(visitor, state)
64+
},
65+
},
66+
}
67+
}

packages/next/build/babel/preset.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,13 @@ module.exports = (
116116
property: 'createElement',
117117
},
118118
],
119+
[
120+
require('./plugins/optimize-hook-destructuring'),
121+
{
122+
// only optimize hook functions imported from React/Preact
123+
lib: true,
124+
},
125+
],
119126
require('@babel/plugin-syntax-dynamic-import'),
120127
require('./plugins/react-loadable-plugin'),
121128
[

test/.stats-app/pages/hooks.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import React, { useState, useCallback } from 'react'
2+
3+
export default () => {
4+
const [clicks1, setClicks1] = React.useState(0)
5+
const [clicks2, setClicks2] = useState(0)
6+
7+
const doClick1 = React.useCallback(() => {
8+
setClicks1(clicks1 + 1)
9+
}, [clicks1])
10+
11+
const doClick2 = useCallback(() => {
12+
setClicks2(clicks2 + 1)
13+
}, [clicks2])
14+
15+
return (
16+
<>
17+
<h3>Clicks {clicks1}</h3>
18+
<button onClick={doClick1}>Click me</button>
19+
20+
<h3>Clicks {clicks2}</h3>
21+
<button onClick={doClick2}>Click me</button>
22+
</>
23+
)
24+
}

test/unit/next-babel.test.js

Lines changed: 82 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -28,71 +28,93 @@ const babel = (code, esm = false) =>
2828
}).code
2929

3030
describe('next/babel', () => {
31-
it('should transform JSX to use a local identifier in modern mode', () => {
32-
const output = babel(`const a = () => <a href="/">home</a>;`, true)
33-
34-
// it should add a React import:
35-
expect(output).toMatch(`import React from"react"`)
36-
// it should hoist JSX factory to a module level variable:
37-
expect(output).toMatch(`var __jsx=React.createElement`)
38-
// it should use that factory for all JSX:
39-
expect(output).toMatch(`__jsx("a",{href:"/"`)
40-
41-
expect(
42-
babel(`const a = ()=><a href="/">home</a>`, true)
43-
).toMatchInlineSnapshot(
44-
`"import React from\\"react\\";var __jsx=React.createElement;var a=function a(){return __jsx(\\"a\\",{href:\\"/\\"},\\"home\\");};"`
45-
)
46-
})
31+
describe('jsx-pragma', () => {
32+
it('should transform JSX to use a local identifier in modern mode', () => {
33+
const output = babel(`const a = () => <a href="/">home</a>;`, true)
4734

48-
it('should transform JSX to use a local identifier in CommonJS mode', () => {
49-
const output = babel(trim`
50-
const a = () => <React.Fragment><a href="/">home</a></React.Fragment>;
51-
`)
52-
53-
// Grab generated names from the compiled output.
54-
// It looks something like this:
55-
// var _react = _interopRequireDefault(require("react"));
56-
// var __jsx = _react["default"].createElement;
57-
// react: _react
58-
// reactNamespace: _react["default"]
59-
const [, react, reactNamespace] = output.match(
60-
/(([a-z0-9_]+)(\[[^\]]*?\]|\.[a-z0-9_]+)*?)\.Fragment/i
61-
)
62-
63-
expect(output).toMatch(`var ${reactNamespace}=`)
64-
expect(output).toMatch(`require("react")`)
65-
expect(output).toMatch(`var __jsx=${react}.createElement`)
66-
// Fragment should use the same React namespace import:
67-
expect(output).toMatch(`__jsx(${react}.Fragment`)
68-
expect(output).toMatch(`__jsx("a",{href:"/"`)
69-
70-
expect(babel(`const a = ()=><a href="/">home</a>`)).toMatchInlineSnapshot(
71-
`"\\"use strict\\";var _interopRequireDefault=require(\\"@babel/runtime-corejs2/helpers/interopRequireDefault\\");var _react=_interopRequireDefault(require(\\"react\\"));var __jsx=_react[\\"default\\"].createElement;var a=function a(){return __jsx(\\"a\\",{href:\\"/\\"},\\"home\\");};"`
72-
)
73-
})
35+
// it should add a React import:
36+
expect(output).toMatch(`import React from"react"`)
37+
// it should hoist JSX factory to a module level variable:
38+
expect(output).toMatch(`var __jsx=React.createElement`)
39+
// it should use that factory for all JSX:
40+
expect(output).toMatch(`__jsx("a",{href:"/"`)
41+
42+
expect(
43+
babel(`const a = ()=><a href="/">home</a>`, true)
44+
).toMatchInlineSnapshot(
45+
`"import React from\\"react\\";var __jsx=React.createElement;var a=function a(){return __jsx(\\"a\\",{href:\\"/\\"},\\"home\\");};"`
46+
)
47+
})
48+
49+
it('should transform JSX to use a local identifier in CommonJS mode', () => {
50+
const output = babel(trim`
51+
const a = () => <React.Fragment><a href="/">home</a></React.Fragment>;
52+
`)
53+
54+
// Grab generated names from the compiled output.
55+
// It looks something like this:
56+
// var _react = _interopRequireDefault(require("react"));
57+
// var __jsx = _react["default"].createElement;
58+
// react: _react
59+
// reactNamespace: _react["default"]
60+
const [, react, reactNamespace] = output.match(
61+
/(([a-z0-9_]+)(\[[^\]]*?\]|\.[a-z0-9_]+)*?)\.Fragment/i
62+
)
63+
64+
expect(output).toMatch(`var ${reactNamespace}=`)
65+
expect(output).toMatch(`require("react")`)
66+
expect(output).toMatch(`var __jsx=${react}.createElement`)
67+
// Fragment should use the same React namespace import:
68+
expect(output).toMatch(`__jsx(${react}.Fragment`)
69+
expect(output).toMatch(`__jsx("a",{href:"/"`)
7470

75-
it('should support Fragment syntax', () => {
76-
const output = babel(`const a = () => <>hello</>;`, true)
71+
expect(babel(`const a = ()=><a href="/">home</a>`)).toMatchInlineSnapshot(
72+
`"\\"use strict\\";var _interopRequireDefault=require(\\"@babel/runtime-corejs2/helpers/interopRequireDefault\\");var _react=_interopRequireDefault(require(\\"react\\"));var __jsx=_react[\\"default\\"].createElement;var a=function a(){return __jsx(\\"a\\",{href:\\"/\\"},\\"home\\");};"`
73+
)
74+
})
7775

78-
expect(output).toMatch(`React.Fragment`)
76+
it('should support Fragment syntax', () => {
77+
const output = babel(`const a = () => <>hello</>;`, true)
7978

80-
expect(babel(`const a = () => <>hello</>;`, true)).toMatchInlineSnapshot(
81-
`"import React from\\"react\\";var __jsx=React.createElement;var a=function a(){return __jsx(React.Fragment,null,\\"hello\\");};"`
82-
)
79+
expect(output).toMatch(`React.Fragment`)
80+
81+
expect(babel(`const a = () => <>hello</>;`, true)).toMatchInlineSnapshot(
82+
`"import React from\\"react\\";var __jsx=React.createElement;var a=function a(){return __jsx(React.Fragment,null,\\"hello\\");};"`
83+
)
84+
})
85+
86+
it('should support commonjs', () => {
87+
const output = babel(
88+
trim`
89+
const React = require('react');
90+
module.exports = () => <div>test2</div>;
91+
`,
92+
true
93+
)
94+
95+
expect(output).toMatchInlineSnapshot(
96+
`"var React=require('react');var __jsx=React.createElement;module.exports=function(){return __jsx(\\"div\\",null,\\"test2\\");};"`
97+
)
98+
})
8399
})
84100

85-
it('should support commonjs', () => {
86-
const output = babel(
87-
trim`
88-
const React = require('react');
89-
module.exports = () => <div>test2</div>;
90-
`,
91-
true
92-
)
93-
94-
expect(output).toMatchInlineSnapshot(
95-
`"var React=require('react');var __jsx=React.createElement;module.exports=function(){return __jsx(\\"div\\",null,\\"test2\\");};"`
96-
)
101+
describe('optimize-hook-destructuring', () => {
102+
it('should transform Array-destructured hook return values use object destructuring', () => {
103+
const output = babel(
104+
trim`
105+
import { useState } from 'react';
106+
const [count, setCount] = useState(0);
107+
`,
108+
true
109+
)
110+
111+
expect(output).toMatch(trim`
112+
var _useState=useState(0),count=_useState[0],setCount=_useState[1];
113+
`)
114+
115+
expect(output).toMatchInlineSnapshot(
116+
`"import{useState}from'react';var _useState=useState(0),count=_useState[0],setCount=_useState[1];"`
117+
)
118+
})
97119
})
98120
})

0 commit comments

Comments
 (0)