Skip to content

Commit 58332d2

Browse files
committed
feat: hoist function declarations to be compliant with default js hoisting behavior
1 parent a3a9704 commit 58332d2

File tree

3 files changed

+202
-1
lines changed

3 files changed

+202
-1
lines changed

src/core/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,11 @@ export type FlytrapConfig = {
161161
* Use this to limit the size of your captures or limit the amount of files that context gets sent from.
162162
*/
163163
captureAmountLimit?: CaptureAmountLimit
164+
/**
165+
* Allows you to disable the default hoisting behavior of function declarations that Flytrap enforces. If you disable function declaration hoisting, wrapped function declarations will no longer go to the top of their respective scopes, and you might run into runtime errors. [Learn more](https://docs.useflytrap.com/troubleshoot/runtime-problems#cannot-access-functionname-before-initialization)
166+
* @default false
167+
*/
168+
disableFunctionDeclarationHoisting?: boolean
164169
}
165170

166171
export type ErrorType = {

src/transform/index.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ import {
2020
isMemberExpression,
2121
isNumericLiteral,
2222
isStringLiteral,
23-
Identifier
23+
Identifier,
24+
ImportDeclaration
2425
} from '@babel/types'
2526
import generate from '@babel/generator'
2627

@@ -178,6 +179,44 @@ export function flytrapTransformUff(
178179
)
179180
])
180181

182+
// Handle function declaration hoisting
183+
if (config?.disableFunctionDeclarationHoisting) {
184+
path.replaceWith(transformedNode)
185+
return
186+
}
187+
188+
const scopePath = path.findParent((parentPath) => {
189+
return parentPath.isBlockStatement() || parentPath.isProgram()
190+
})
191+
192+
if (scopePath) {
193+
let lastImportPath: NodePath<ImportDeclaration> | undefined = undefined
194+
const bodyNode = scopePath.get('body')
195+
if (Array.isArray(bodyNode)) {
196+
bodyNode.forEach((bodyPath) => {
197+
if (bodyPath.isImportDeclaration()) {
198+
lastImportPath = bodyPath
199+
}
200+
})
201+
} else if (bodyNode.isImportDeclaration()) {
202+
lastImportPath = bodyNode
203+
}
204+
205+
if (lastImportPath) {
206+
// Insert after the last import statement
207+
lastImportPath.insertAfter(transformedNode)
208+
} else {
209+
// @ts-expect-error: Otherwise, insert at the top of the current scope
210+
scopePath.unshiftContainer('body', transformedNode)
211+
}
212+
213+
// Remove the original function declaration
214+
path.remove()
215+
return
216+
} else {
217+
// @todo: add warning
218+
}
219+
// No hoisting if there is no parent scope
181220
path.replaceWith(transformedNode)
182221
}
183222
}),

test/transform-uff.test.ts

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,3 +205,160 @@ describe('calls', () => {
205205
})
206206
}
207207
})
208+
209+
const functionHoistingFixture: Record<string, TransformFixture[]> = {
210+
'hoists top level scope': [
211+
{
212+
fixture: `
213+
console.log(foo());
214+
function foo() {}
215+
`,
216+
expected: `
217+
const foo = uff(function foo() {}, '/file.js-_foo');
218+
console.log(foo());
219+
`
220+
}
221+
],
222+
'hoists inside arrow functions': [
223+
{
224+
fixture: `
225+
() => {
226+
console.log(foo());
227+
function foo() {}
228+
}
229+
`,
230+
expected: `
231+
uff(() => {
232+
const foo = uff(function foo() {}, '/file.js-_anonymousFoo');
233+
console.log(foo());
234+
}, '/file.js-_anonymous')
235+
`
236+
}
237+
],
238+
'hoists inside function expressions': [
239+
{
240+
fixture: `
241+
const x = function() {
242+
console.log(foo());
243+
function foo() {}
244+
}
245+
`,
246+
expected: `
247+
const x = uff(function() {
248+
const foo = uff(function foo() {}, '/file.js-_anonymousFoo');
249+
console.log(foo());
250+
}, '/file.js-_x')
251+
`
252+
}
253+
],
254+
'hoists inside function declarations': [
255+
{
256+
fixture: `
257+
function bar() {
258+
console.log(foo());
259+
function foo() {}
260+
}
261+
`,
262+
expected: `
263+
const bar = uff(function bar() {
264+
const foo = uff(function foo() {}, '/file.js-_barFoo');
265+
console.log(foo());
266+
}, '/file.js-_bar')
267+
`
268+
}
269+
],
270+
'hoists inside block scopes': [
271+
{
272+
fixture: `
273+
{
274+
console.log(foo());
275+
function foo() {}
276+
}
277+
`,
278+
expected: `
279+
{
280+
const foo = uff(function foo() {}, '/file.js-_foo');
281+
console.log(foo());
282+
}`
283+
}
284+
],
285+
'hoisted functions continue being traversed and transformed': [
286+
{
287+
fixture: `
288+
console.log(foo())
289+
function foo() {
290+
const bar = () => {};
291+
}
292+
`,
293+
expected: `
294+
const foo = uff(function foo() {
295+
const bar = uff(() => {}, '/file.js-_fooBar')
296+
}, '/file.js-_foo')
297+
console.log(foo())
298+
`
299+
}
300+
],
301+
'code doesnt get hoisted above directives': [
302+
{
303+
fixture: `
304+
'use client';
305+
306+
console.log(foo());
307+
function foo() {}
308+
`,
309+
expected: `
310+
'use client';
311+
312+
const foo = uff(function foo() {}, '/file.js-_foo');
313+
console.log(foo());`
314+
},
315+
{
316+
fixture: `
317+
async function myAction() {
318+
'use server'
319+
320+
console.log(foo());
321+
function foo() {}
322+
}`,
323+
expected: `
324+
const myAction = uff(async function myAction() {
325+
'use server'
326+
327+
const foo = uff(function foo() {}, '/file.js-_myActionFoo');
328+
console.log(foo());
329+
}, '/file.js-_myAction')
330+
`
331+
}
332+
],
333+
'code doesnt get hoisted above imports': [
334+
{
335+
fixture: `
336+
import { uff } from 'useflytrap';
337+
338+
console.log(foo());
339+
function foo() {}
340+
`,
341+
expected: `
342+
import { uff } from 'useflytrap';
343+
const foo = uff(function foo() {}, '/file.js-_foo');
344+
console.log(foo());
345+
`
346+
}
347+
]
348+
}
349+
350+
describe('function declaration hoisting', () => {
351+
for (const [suiteName, suiteFixtures] of Object.entries(functionHoistingFixture)) {
352+
it(suiteName, () => {
353+
for (let i = 0; i < suiteFixtures.length; i++) {
354+
const { code } = flytrapTransformUff(suiteFixtures[i].fixture, '/file.js', {
355+
transformOptions: {
356+
disableTransformation: ['call-expression']
357+
}
358+
})
359+
360+
expect(toOneLine(code)).toBe(toOneLine(suiteFixtures[i].expected))
361+
}
362+
})
363+
}
364+
})

0 commit comments

Comments
 (0)