From b59563774eca8616fe97624df45cc3f03381265f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:48:44 -0700 Subject: [PATCH] test(mobile): parse Expo route exports --- mobile/src/expo-route-module-boundary.test.ts | 85 ++++++++++++++++++- 1 file changed, 82 insertions(+), 3 deletions(-) diff --git a/mobile/src/expo-route-module-boundary.test.ts b/mobile/src/expo-route-module-boundary.test.ts index 3e4173d4f6f..25ae5473207 100644 --- a/mobile/src/expo-route-module-boundary.test.ts +++ b/mobile/src/expo-route-module-boundary.test.ts @@ -1,9 +1,11 @@ import { readFileSync, readdirSync } from 'node:fs' import { fileURLToPath } from 'node:url' -import { extname, join, relative } from 'node:path' +import { basename, dirname, extname, join, relative } from 'node:path' +import ts from 'typescript' import { describe, expect, it } from 'vitest' const appDirectory = fileURLToPath(new URL('../app', import.meta.url)) +const routeSourceExtensions = new Set(['.js', '.jsx', '.ts', '.tsx']) function sourceFiles(directory: string): string[] { return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { @@ -12,11 +14,88 @@ function sourceFiles(directory: string): string[] { }) } +function isNonScreenExpoModule(path: string): boolean { + const fileName = basename(path) + if (/\+api(?:\.\w+)?\.[jt]sx?$/.test(fileName)) { + return true + } + return ( + dirname(relative(appDirectory, path)) === '.' && + /^\+(?:html|middleware|native-intent)\.[jt]sx?$/.test(fileName) + ) +} + +function hasDefaultExport(path: string, source: string): boolean { + const extension = extname(path) + const sourceFile = ts.createSourceFile( + path, + source, + ts.ScriptTarget.Latest, + true, + extension === '.jsx' + ? ts.ScriptKind.JSX + : extension === '.js' + ? ts.ScriptKind.JS + : extension === '.tsx' + ? ts.ScriptKind.TSX + : ts.ScriptKind.TS + ) + + return sourceFile.statements.some((statement) => { + if (ts.isExportAssignment(statement)) { + return !statement.isExportEquals + } + if (ts.isExportDeclaration(statement) && !statement.isTypeOnly && statement.exportClause) { + if (ts.isNamespaceExport(statement.exportClause)) { + return statement.exportClause.name.text === 'default' + } + return statement.exportClause.elements.some( + (element) => !element.isTypeOnly && element.name.text === 'default' + ) + } + const modifiers = ts.canHaveModifiers(statement) ? ts.getModifiers(statement) : undefined + return ( + !ts.isInterfaceDeclaration(statement) && + !ts.isTypeAliasDeclaration(statement) && + modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.DeclareKeyword) !== true && + modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) === true && + modifiers.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword) + ) + }) +} + describe('Expo route module boundary', () => { + it('allows Expo modules that are not screen routes', () => { + expect(isNonScreenExpoModule(join(appDirectory, 'health+api.ts'))).toBe(true) + expect(isNonScreenExpoModule(join(appDirectory, 'health+api.ios.ts'))).toBe(true) + expect(isNonScreenExpoModule(join(appDirectory, '+html.tsx'))).toBe(true) + expect(isNonScreenExpoModule(join(appDirectory, '+middleware.ts'))).toBe(true) + expect(isNonScreenExpoModule(join(appDirectory, '+native-intent.ts'))).toBe(true) + expect(isNonScreenExpoModule(join(appDirectory, 'nested', '+middleware.ts'))).toBe(false) + }) + + it('recognizes syntax-level default exports', () => { + expect(hasDefaultExport('route.tsx', 'export default function Route() {}')).toBe(true) + expect( + hasDefaultExport('route.jsx', 'export default function Route() { return }') + ).toBe(true) + expect(hasDefaultExport('route.ts', "export { default } from './route-screen'")).toBe(true) + expect(hasDefaultExport('route.ts', "export { Route as default } from './route-screen'")).toBe( + true + ) + expect(hasDefaultExport('support.ts', '// export default')).toBe(false) + expect(hasDefaultExport('support.ts', "const marker = 'export default'")).toBe(false) + expect(hasDefaultExport('support.ts', 'export default interface Support {}')).toBe(false) + expect( + hasDefaultExport('support.ts', "export type { Support as default } from './types'") + ).toBe(false) + }) + it('keeps support modules outside the app route directory', () => { const invalidRoutes = sourceFiles(appDirectory) - .filter((path) => ['.ts', '.tsx'].includes(extname(path))) - .filter((path) => !readFileSync(path, 'utf8').includes('export default')) + .filter((path) => routeSourceExtensions.has(extname(path))) + .filter((path) => !isNonScreenExpoModule(path)) + .filter((path) => !hasDefaultExport(path, readFileSync(path, 'utf8'))) .map((path) => relative(appDirectory, path)) expect(invalidRoutes).toEqual([])