Skip to content

Commit

Permalink
Support Remix v2 in route generator (#756)
Browse files Browse the repository at this point in the history
* Support v2_routeConvention when generating routes

* Cleanup skeleton imports

* Support v2_meta format in skeleton

* Support v2_meta in hello-world template

* Changeset

* Fix double export keyword

* Refactor

* Enable flags automatically for Remix v2

* Fix: import config using ESM

* Revert code that will be removed in another PR

* Refactor

* Extract v2flags logic

* Default to v2_meta:true in generators and hello-world

* Extract tests specific to Remix v2 flags

* Support v2_errorBoundary in generators and enable it by default in hello-world

* Enable v2_routeConvention by default in hello-world

* Cleanup

* Detect v1 route convention package

* Allow function exports for meta en error boundaries

* Add docs for v1-v2 interop in skeleton routes

* Move V2_MetaFunction import to remix-run/react

* Change where V2_MetaFunction is imported from in Remix 1.15.0

* Cleanup
  • Loading branch information
frandiox authored Apr 7, 2023
1 parent d6e612f commit e6e6c2d
Show file tree
Hide file tree
Showing 15 changed files with 363 additions and 60 deletions.
5 changes: 5 additions & 0 deletions .changeset/clever-buses-cry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/cli-hydrogen': patch
---

Add support for the Remix future flags `v2_meta`, `v2_errorBoundary` and `v2_routeConvention` to the `generate` command. If these flags are enabled in your project, the new generated files will follow the v2 conventions.
34 changes: 30 additions & 4 deletions packages/cli/src/commands/hydrogen/generate/route.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {describe, it, expect, vi, beforeEach} from 'vitest';
import {temporaryDirectoryTask} from 'tempy';
import {runGenerate, GENERATOR_TEMPLATES_DIR} from './route.js';
import {convertRouteToV2} from '../../../utils/remix-version-interop.js';

import {file, path, ui} from '@shopify/cli-kit';

describe('generate/route', () => {
Expand Down Expand Up @@ -31,7 +33,7 @@ describe('generate/route', () => {
});

// When
await runGenerate(route, {
await runGenerate(route, route, {
directory: appRoot,
templatesRoot,
});
Expand All @@ -43,6 +45,30 @@ describe('generate/route', () => {
});
});

it('generates a route file for Remix v2', async () => {
await temporaryDirectoryTask(async (tmpDir) => {
// Given
const route = 'custom/path/$handle/index';
const {appRoot, templatesRoot} = await createHydrogen(tmpDir, {
files: [],
templates: [[route, `const str = "hello world"`]],
});

// When
await runGenerate(route, convertRouteToV2(route), {
directory: appRoot,
templatesRoot,
});

// Then
expect(
await file.read(
path.join(appRoot, 'app/routes', `custom.path.$handle._index.jsx`),
),
).toContain(`const str = 'hello world'`);
});
});

it('produces a typescript file when typescript argument is true', async () => {
await temporaryDirectoryTask(async (tmpDir) => {
// Given
Expand All @@ -53,7 +79,7 @@ describe('generate/route', () => {
});

// When
await runGenerate(route, {
await runGenerate(route, route, {
directory: appRoot,
templatesRoot,
typescript: true,
Expand All @@ -80,7 +106,7 @@ describe('generate/route', () => {
});

// When
await runGenerate(route, {
await runGenerate(route, route, {
directory: appRoot,
templatesRoot,
});
Expand Down Expand Up @@ -110,7 +136,7 @@ describe('generate/route', () => {
});

// When
await runGenerate(route, {
await runGenerate(route, route, {
directory: appRoot,
templatesRoot,
force: true,
Expand Down
39 changes: 29 additions & 10 deletions packages/cli/src/commands/hydrogen/generate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ import {
transpileFile,
resolveFormatConfig,
} from '../../../utils/transpile-ts.js';
import {
convertRouteToV2,
convertTemplateToRemixVersion,
getV2Flags,
type RemixV2Flags,
} from '../../../utils/remix-version-interop.js';

export const GENERATOR_TEMPLATES_DIR = 'generator-templates';

Expand Down Expand Up @@ -66,11 +72,10 @@ export default class GenerateRoute extends Command {

async run(): Promise<void> {
const result = new Map<string, Result>();
// @ts-ignore
const {flags, args} = await this.parse(GenerateRoute);
const directory = flags.path ? path.resolve(flags.path) : process.cwd();

const {route} = args;
const {
flags,
args: {route},
} = await this.parse(GenerateRoute);

const routePath =
route === 'all'
Expand All @@ -82,21 +87,30 @@ export default class GenerateRoute extends Command {
`No route found for ${route}. Try one of ${ROUTES.join()}.`,
);
}

const directory = flags.path ? path.resolve(flags.path) : process.cwd();

const isTypescript =
flags.typescript ||
(await file.exists(path.join(directory, 'tsconfig.json')));

const routesArray = Array.isArray(routePath) ? routePath : [routePath];

try {
const {isV2RouteConvention, ...v2Flags} = await getV2Flags(directory);

for (const item of routesArray) {
const routeFrom = item;
const routeTo = isV2RouteConvention ? convertRouteToV2(item) : item;

result.set(
item,
await runGenerate(item, {
routeTo,
await runGenerate(routeFrom, routeTo, {
directory,
typescript: isTypescript,
force: flags.force,
adapter: flags.adapter,
v2Flags,
}),
);
}
Expand Down Expand Up @@ -127,19 +141,22 @@ export default class GenerateRoute extends Command {
}

export async function runGenerate(
route: string,
routeFrom: string,
routeTo: string,
{
directory,
typescript,
force,
adapter,
templatesRoot = fileURLToPath(new URL('../../../', import.meta.url)),
v2Flags = {},
}: {
directory: string;
typescript?: boolean;
force?: boolean;
adapter?: string;
templatesRoot?: string;
v2Flags?: RemixV2Flags;
},
): Promise<Result> {
let operation;
Expand All @@ -148,13 +165,13 @@ export async function runGenerate(
templatesRoot,
GENERATOR_TEMPLATES_DIR,
'routes',
`${route}.tsx`,
`${routeFrom}.tsx`,
);
const destinationPath = path.join(
directory,
'app',
'routes',
`${route}${extension}`,
`${routeTo}${extension}`,
);
const relativeDestinationPath = path.relative(directory, destinationPath);

Expand Down Expand Up @@ -186,6 +203,8 @@ export async function runGenerate(

let templateContent = await file.read(templatePath);

templateContent = convertTemplateToRemixVersion(templateContent, v2Flags);

// If the project is not using TypeScript, we need to compile the template
// to JavaScript. We try to read the project's jsconfig.json, but if it
// doesn't exist, we use a default configuration.
Expand Down
10 changes: 4 additions & 6 deletions packages/cli/src/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ const BUILD_DIR = 'dist'; // Hardcoded in Oxygen
const CLIENT_SUBDIR = 'client';
const WORKER_SUBDIR = 'worker'; // Hardcoded in Oxygen

const oxygenServerMainFields = ['browser', 'module', 'main'];

export function getProjectPaths(appPath?: string, entry?: string) {
const root = appPath ?? process.cwd();
const publicPath = path.join(root, 'public');
Expand Down Expand Up @@ -79,17 +81,13 @@ export async function getRemixConfig(
);
}

const expectedServerMainFields = ['browser', 'module', 'main'];

if (
!config.serverMainFields ||
!expectedServerMainFields.every(
(v, i) => config.serverMainFields?.[i] === v,
)
!oxygenServerMainFields.every((v, i) => config.serverMainFields?.[i] === v)
) {
throwConfigError(
`The serverMainFields in remix.config.js must be ${JSON.stringify(
expectedServerMainFields,
oxygenServerMainFields,
)}.`,
);
}
Expand Down
102 changes: 102 additions & 0 deletions packages/cli/src/utils/remix-version-interop.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import {describe, it, expect} from 'vitest';
import {convertTemplateToRemixVersion} from './remix-version-interop.js';

describe('remix-version-interop', () => {
describe('v2_meta', () => {
const META_TEMPLATE = `
import {type MetaFunction} from '@shopify/remix-oxygen';
import {type V2_MetaFunction} from '@remix-run/react';
export const metaV1: MetaFunction = ({data}) => {
const title = 'title';
return {title};
};
export const meta: V2_MetaFunction = ({data}) => {
const title = 'title';
return [{title}];
};
`.replace(/^\s{4}/gm, '');

it('transforms meta exports to v2', async () => {
const result = convertTemplateToRemixVersion(META_TEMPLATE, {
isV2Meta: true,
});

expect(result).toContain('type V2_MetaFunction');
expect(result).not.toContain('type MetaFunction');
expect(result).not.toContain('@shopify/remix-oxygen');
expect(result).toMatch(/return \[\{title\}\];/);
expect(result).not.toMatch(/return \{title\};/);
});

it('transforms meta exports to v1', async () => {
const result = convertTemplateToRemixVersion(META_TEMPLATE, {
isV2Meta: false,
});

expect(result).toContain('type MetaFunction');
expect(result).not.toContain('type V2_MetaFunction');
expect(result).not.toContain('@remix-run/react');
expect(result).toMatch(/return \{title\};/);
expect(result).not.toMatch(/return \[\{title\}\];/);
});
});

describe('v2_errorBoundary', () => {
const ERROR_BOUNDARY_TEMPLATE = `
import {useCatch, isRouteErrorResponse, useRouteError} from "@remix-run/react";
import {type ErrorBoundaryComponent} from '@shopify/remix-oxygen';
export function CatchBoundary() {
const caught = useCatch();
console.error(caught);
return <div>stuff</div>;
}
export const ErrorBoundaryV1: ErrorBoundaryComponent = ({error}) => {
console.error(error);
return <div>There was an error.</div>;
};
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return <div>RouteError</div>;
} else {
return <h1>Unknown Error</h1>;
}
}
`.replace(/^\s{4}/gm, '');

it('transforms ErrorBoundary exports to v2', async () => {
const result = convertTemplateToRemixVersion(ERROR_BOUNDARY_TEMPLATE, {
isV2ErrorBoundary: true,
});

expect(result).toContain('export function ErrorBoundary');
expect(result).not.toContain('export const ErrorBoundary');
expect(result).not.toMatch('export function CatchBoundary');
expect(result).not.toContain('type ErrorBoundaryComponent');
expect(result).not.toContain('@shopify/remix-oxygen'); // Cleans empty up imports
expect(result).toContain('useRouteError');
expect(result).toContain('isRouteErrorResponse');
expect(result).not.toContain('useCatch');
});

it('transforms ErrorBoundary exports to v1', async () => {
const result = convertTemplateToRemixVersion(ERROR_BOUNDARY_TEMPLATE, {
isV2ErrorBoundary: false,
});

expect(result).toContain('export const ErrorBoundary');
expect(result).not.toContain('export function ErrorBoundary');
expect(result).toMatch('export function CatchBoundary');
expect(result).toContain('type ErrorBoundaryComponent');
expect(result).toContain('useCatch');
expect(result).not.toContain('useRouteError');
expect(result).not.toContain('isRouteErrorResponse');
});
});
});
Loading

0 comments on commit e6e6c2d

Please sign in to comment.