Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';

import { SupportedLanguage } from 'storybook/internal/types';

import { describe, expect, it } from 'vitest';

import { generateReactNativeEntrypoint, getEntrypointTemplatePath } from './generateEntrypoint.ts';

describe('generateReactNativeEntrypoint', () => {
it('resolves Expo template path when expo variant is requested', () => {
const templatePath = getEntrypointTemplatePath('expo');

expect(path.basename(templatePath)).toMatchInlineSnapshot(`"index.expo.js"`);
});

it('generates .rnstorybook/index.ts for TypeScript projects', async () => {
const cwd = await mkdtemp(path.join(os.tmpdir(), 'sb-rn-entry-ts-'));

try {
const result = await generateReactNativeEntrypoint({
language: SupportedLanguage.TYPESCRIPT,
cwd,
});
const outputPath = path.join(cwd, '.rnstorybook', 'index.ts');
const output = await readFile(outputPath, 'utf-8');

expect(path.relative(cwd, result.targetPath)).toMatchInlineSnapshot(
`".rnstorybook/index.ts"`
);
expect(output).toMatchInlineSnapshot(`
"import { AppRegistry } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';

import { view } from './storybook.requires';

/**
* This file is user-editable.
*
* Use it as your React Native Storybook entrypoint and wrap \`StorybookUIRoot\`
* with application decorators/providers (theme, i18n, state, navigation, etc).
*/
const StorybookUIRoot = view.getStorybookUI({
shouldPersistSelection: true,
storage: {
getItem: AsyncStorage.getItem,
setItem: AsyncStorage.setItem,
},
});

AppRegistry.registerComponent('main', () => StorybookUIRoot);

export default StorybookUIRoot;
"
`);
} finally {
await rm(cwd, { recursive: true, force: true });
}
});

it('generates .rnstorybook/index.js for JavaScript projects', async () => {
const cwd = await mkdtemp(path.join(os.tmpdir(), 'sb-rn-entry-js-'));

try {
const result = await generateReactNativeEntrypoint({
language: SupportedLanguage.JAVASCRIPT,
cwd,
});
const outputPath = path.join(cwd, '.rnstorybook', 'index.js');
const output = await readFile(outputPath, 'utf-8');

expect(path.relative(cwd, result.targetPath)).toMatchInlineSnapshot(
`".rnstorybook/index.js"`
);
expect(output).toMatchInlineSnapshot(`
"import { AppRegistry } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';

import { view } from './storybook.requires';

/**
* This file is user-editable.
*
* Use it as your React Native Storybook entrypoint and wrap \`StorybookUIRoot\`
* with application decorators/providers (theme, i18n, state, navigation, etc).
*/
const StorybookUIRoot = view.getStorybookUI({
shouldPersistSelection: true,
storage: {
getItem: AsyncStorage.getItem,
setItem: AsyncStorage.setItem,
},
});

AppRegistry.registerComponent('main', () => StorybookUIRoot);

export default StorybookUIRoot;
"
`);
} finally {
await rm(cwd, { recursive: true, force: true });
}
});

it('generates Expo-specific entrypoint contents for expo projects', async () => {
const cwd = await mkdtemp(path.join(os.tmpdir(), 'sb-rn-entry-expo-'));

try {
const result = await generateReactNativeEntrypoint({
language: SupportedLanguage.TYPESCRIPT,
templateVariant: 'expo',
cwd,
});
const outputPath = path.join(cwd, '.rnstorybook', 'index.ts');
const output = await readFile(outputPath, 'utf-8');

expect(path.relative(cwd, result.targetPath)).toMatchInlineSnapshot(
`".rnstorybook/index.ts"`
);
expect(output).toMatchInlineSnapshot(`
"import { registerRootComponent } from 'expo';
import AsyncStorage from '@react-native-async-storage/async-storage';

import { view } from './storybook.requires';

/**
* This file is user-editable.
*
* Use it as your React Native Storybook entrypoint and wrap \`StorybookUIRoot\`
* with application decorators/providers (theme, i18n, state, navigation, etc).
*/
const StorybookUIRoot = view.getStorybookUI({
shouldPersistSelection: true,
storage: {
getItem: AsyncStorage.getItem,
setItem: AsyncStorage.setItem,
},
});

registerRootComponent(StorybookUIRoot);
"
`);
} finally {
await rm(cwd, { recursive: true, force: true });
}
});

it('overwrites existing target index file on rerun', async () => {
const cwd = await mkdtemp(path.join(os.tmpdir(), 'sb-rn-entry-overwrite-'));
const targetPath = path.join(cwd, '.rnstorybook', 'index.js');

try {
await mkdir(path.dirname(targetPath), { recursive: true });
await writeFile(targetPath, 'export default function Old() {};\n', 'utf-8');

await generateReactNativeEntrypoint({
language: SupportedLanguage.JAVASCRIPT,
cwd,
});
const output = await readFile(targetPath, 'utf-8');

expect(output).toMatchInlineSnapshot(`
"import { AppRegistry } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';

import { view } from './storybook.requires';

/**
* This file is user-editable.
*
* Use it as your React Native Storybook entrypoint and wrap \`StorybookUIRoot\`
* with application decorators/providers (theme, i18n, state, navigation, etc).
*/
const StorybookUIRoot = view.getStorybookUI({
shouldPersistSelection: true,
storage: {
getItem: AsyncStorage.getItem,
setItem: AsyncStorage.setItem,
},
});

AppRegistry.registerComponent('main', () => StorybookUIRoot);

export default StorybookUIRoot;
"
`);
} finally {
await rm(cwd, { recursive: true, force: true });
}
});

it('keeps sibling extension file when generating target extension', async () => {
const cwd = await mkdtemp(path.join(os.tmpdir(), 'sb-rn-entry-sibling-'));
const tsPath = path.join(cwd, '.rnstorybook', 'index.ts');

try {
await mkdir(path.dirname(tsPath), { recursive: true });
await writeFile(tsPath, '// sibling\n', 'utf-8');

await generateReactNativeEntrypoint({
language: SupportedLanguage.JAVASCRIPT,
cwd,
});

const sibling = await readFile(tsPath, 'utf-8');
const jsPath = path.join(cwd, '.rnstorybook', 'index.js');
const generated = await readFile(jsPath, 'utf-8');

expect(sibling).toMatchInlineSnapshot(`
"// sibling
"
`);
expect(generated).toMatchInlineSnapshot(`
"import { AppRegistry } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';

import { view } from './storybook.requires';

/**
* This file is user-editable.
*
* Use it as your React Native Storybook entrypoint and wrap \`StorybookUIRoot\`
* with application decorators/providers (theme, i18n, state, navigation, etc).
*/
const StorybookUIRoot = view.getStorybookUI({
shouldPersistSelection: true,
storage: {
getItem: AsyncStorage.getItem,
setItem: AsyncStorage.setItem,
},
});

AppRegistry.registerComponent('main', () => StorybookUIRoot);

export default StorybookUIRoot;
"
`);
} finally {
await rm(cwd, { recursive: true, force: true });
}
});

it('does not modify existing storybook.requires file', async () => {
const cwd = await mkdtemp(path.join(os.tmpdir(), 'sb-rn-entry-requires-'));
const requiresPath = path.join(cwd, '.rnstorybook', 'storybook.requires.ts');
const originalRequires = 'export const view = {};\n';

try {
await mkdir(path.dirname(requiresPath), { recursive: true });
await writeFile(requiresPath, originalRequires, 'utf-8');

await generateReactNativeEntrypoint({
language: SupportedLanguage.TYPESCRIPT,
cwd,
});

const requiresOutput = await readFile(requiresPath, 'utf-8');
expect(requiresOutput).toMatchInlineSnapshot(`
"export const view = {};
"
`);
} finally {
await rm(cwd, { recursive: true, force: true });
}
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path, { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

import { RN_STORYBOOK_DIR } from '../../../../../core/src/shared/constants/config-folder.ts';
import { SupportedLanguage } from 'storybook/internal/types';

const ENTRYPOINT_TEMPLATE_DIR = join(
dirname(fileURLToPath(import.meta.resolve('create-storybook/package.json'))),
'templates',
'react-native'
);

export type ReactNativeEntrypointTemplateVariant = 'default' | 'expo';

export const getEntrypointExtension = (language: SupportedLanguage) => {
return language === SupportedLanguage.TYPESCRIPT ? 'ts' : 'js';
};

export const getEntrypointTemplatePath = (
templateVariant: ReactNativeEntrypointTemplateVariant = 'default'
) => {
const templateFileName = templateVariant === 'expo' ? 'index.expo.js' : 'index.js';
return join(ENTRYPOINT_TEMPLATE_DIR, templateFileName);
};

export const generateReactNativeEntrypoint = async ({
language,
templateVariant = 'default',
cwd = process.cwd(),
}: {
language: SupportedLanguage;
templateVariant?: ReactNativeEntrypointTemplateVariant;
cwd?: string;
}) => {
const extension = getEntrypointExtension(language);
const templatePath = getEntrypointTemplatePath(templateVariant);
const targetDir = path.join(cwd, RN_STORYBOOK_DIR);
const targetPath = path.join(targetDir, `index.${extension}`);

const templateContents = await readFile(templatePath, 'utf-8');

await mkdir(targetDir, { recursive: true });
await writeFile(targetPath, templateContents, 'utf-8');

Comment thread
ndelangen marked this conversation as resolved.
return {
targetPath,
extension,
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';

import { detectReactNativeEntrypointTemplateVariant } from './index.ts';

describe('detectReactNativeEntrypointTemplateVariant', () => {
it('returns expo when expo dependency is present', () => {
expect(
detectReactNativeEntrypointTemplateVariant({
expo: '^51.0.0',
'react-native': '0.76.0',
})
).toBe('expo');
});

it('returns expo when expo-router dependency is present', () => {
expect(
detectReactNativeEntrypointTemplateVariant({
'expo-router': '^4.0.0',
'react-native': '0.76.0',
})
).toBe('expo');
});

it('returns default when expo dependencies are missing', () => {
expect(
detectReactNativeEntrypointTemplateVariant({
'react-native': '0.76.0',
})
).toBe('default');
});
});
22 changes: 22 additions & 0 deletions code/lib/create-storybook/src/generators/REACT_NATIVE/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,22 @@ import { SupportedBuilder, SupportedLanguage, SupportedRenderer } from 'storyboo

import { dedent } from 'ts-dedent';

import {
type ReactNativeEntrypointTemplateVariant,
generateReactNativeEntrypoint,
} from './generateEntrypoint.ts';
import { defineGeneratorModule } from '../modules/GeneratorModule.ts';

export const detectReactNativeEntrypointTemplateVariant = (
allDependencies: Record<string, string>
) => {
if (allDependencies.expo || allDependencies['expo-router']) {
return 'expo';
}

return 'default';
};

export default defineGeneratorModule({
metadata: {
projectType: ProjectType.REACT_NATIVE,
Expand Down Expand Up @@ -63,6 +77,14 @@ export default defineGeneratorModule({
features: context.features,
});

const templateVariant: ReactNativeEntrypointTemplateVariant =
detectReactNativeEntrypointTemplateVariant(packageManager.getAllDependencies());

await generateReactNativeEntrypoint({
language: context.language,
templateVariant,
});

// React Native doesn't use baseGenerator - return special config
return {
// Signal to skip baseGenerator by returning minimal config
Expand Down
Loading
Loading