Cookpad Online Spring Internship 2021 と Hackarade を合同開催しました

ユーザー・決済基盤部の三吉(@sankichi92)です。 昨年よりエンジニアの立場から新卒採用を担当しています。

2月の記事で告知したスプリングインターンシップを 3/22〜26 の日程で開催しました。 また、同時に社内でも Hackrade Remote #2 を開催し、社員もインターン生と同じ課題に取り組みました。 この記事はその開催レポートです。

合同開催について

今回のインターンシップのコンセプトは「クックパッドの社内ハッカソン "Hackarade" を体験してみよう!」というものでした。 Hackarade は Hack + Parade を組み合わせた社内の造語で、ハッカソンではあるものの「競技」より「祭り」の側面が強い社内イベントです。 アイデアや成果を競い合うのではなく、社内エンジニアの技術力向上を目的としています。 過去の Hackarade については、"Hackarade" でブログ内を検索してみてください。

そして、せっかく Hackarade をやるのならインターン生だけではもったいない、ということで社内 Hackarade の合同開催が決まりました。 具体的には、インターンシップと同じ期間に社内でも Hackarade を開催し、成果発表をインターン生と合同で行うことにしました。 以下は、合同開催が決定した時のツイートです。

クックパッドでは昨年の春よりオンラインでインターンシップを開催していますが、オンラインでは社内の雰囲気が伝わりづらいという課題がありました。 合同開催には、実際の社内イベントと重ねて関わる社員を増やすことで、会社の様子を少しでもイメージしやすくする狙いもあります。 実際、インターン生がシングルチャンネルゲストとして参加する Slack チャンネルは、これまでのインターンシップと比べても非常に活発でした。 スクリーンショットのように、インターン生21人を含む83人から5日間で3,000件を超えるメッセージが投稿されました1f:id:sankichi92:20210401000058p:plain

講義動画について

Hackarade には毎回テーマがあり、社内の第一人者による講義を受けたのち開発に取り組む形式が基本です。 今回のインターンシップ & Hackarade のテーマは「Web フロントエンド」で、講師を外村(@hokaccha)が務めました。 また、オンラインのメリットを活かす取り組みとして、講義動画を事前に撮影し、参加者各自の好きなタイミングで視聴してもらう形を取りました。 わからなかったところは何度も見返したり、知っている内容は倍速で飛ばしたりできると、インターン生・社員ともに好評でした。

ここで、実際に使用した講義動画を公開します。

講義動画はトピックごとに以下の4つからなります。

  1. JavaScript
  2. TypeScript
  3. React
  4. Next.js

モダン Web フロントエンドに入門したい方や、上に挙げたようなトピックをおさらいしたい方など、ぜひご覧になってください。

インターンシップについて

インターンシップは、初日に講義動画の共有や課題の発表を行い、最終日5日目に成果発表してもらう、というスケジュールでした。 その間、質問は Slack で受け付け、講師・TA の誰か回答できる人が回答します。 テキストだけで難しい場合は、Zoom を使用することもありました。

課題は、Next.js と TypeScript を使ってレシピサイトを作る、というものでした。 基本課題と発展課題に分かれており、基本課題は講義動画を見ればだいたいできる内容です。 一方、発展課題は Web フロントエンドに慣れている人でも5日間やることがなくならないよう、かなりのボリュームになっています。 詳細は https://gist.github.com/hokaccha/7003c700f7d2ad276bfb458edd862abe をご覧ください。 データについて、今回はインターンシップ用に Web API を用意しました。

講師の想定を超えて、基本課題を2日目の朝6時に終える猛者や、すべての発展課題に取り組む猛者がいました。 同じ課題に取り組んでいても、成果発表では参加者ごとの個性がよく出ていて感心しました。

今回のインターンシップでの大きなチャレンジは、従来のような対面型でのインターンを単にオンラインにするのではなく、オンラインならではの効果が得られるような実施方法を模索することでした。 先ほど触れた講義動画はそのひとつです。 会場という制約がないので、必ず全員が参加しなければならない場面も減らすことができます。 5日間を通して、時間を合わせて集まるのは初日と最終日のみの計5時間ほどでした。 2〜4日目は、毎日1時間オフィスアワーとして講師・TAが Zoom に待機する時間を設けていましたが、そちらよりむしろテキストのコミュニケーション方が活発でした。 3日目には、Tech MTG という隔週で開催しているエンジニア全員参加のミーティングを見学する機会も設けましたが、これも任意参加です。 事前に2〜4日目の時間の使い方は自由とアナウンスしていたので、インターンシップ期間中に研究したり、卒業式に出席したり、引越ししたりする参加者がいました。 インターンシップの様子は、Twitter ハッシュタグ #cookpad_spring_intern から覗くことができます。

Hackarade について

社内の Hackarade は、前回のリモート開催時同様、開催期間のうち「8時間まで開発に使ってよい」というルールにしました。 また、インターンシップと異なり、以下の2部門があります。

  • 規定部門: インターン生向け課題のレギューレーションに従って開発する
  • 自由部門: 講義内容の技術を使って自由に開発する

規定部門はインターン生が5日間かけて取り組むボリュームということもあり、自由部門のエントリーが多くなりました。 ここからはいくつか作品をピックアップして紹介します。

Ruby コミッターの @mametter からは「ブラウザの上で Ruby を動かす」という発表がありました。 Web フロントエンドがテーマのはずなのに、irb が(ブラウザで)動き始めるという、予想の斜め上をいく作品でした。

また、おまけとして1時間(!)で書いた Next.js を使った Quine の紹介もありました。 遠藤さんの Quine は社内で有名ですが、インターン生にはどう見えたのか気になるところです。

買物プロダクト開発部の @solt9029 からは「ズルできるババ抜き」の発表がありました。 発表中は「これを作ろうと思った思考プロセスが気になる」といったコメントが寄せられました。

他にも、分析 SQL のシェアができるアプリや、GHE・Slack などから社内情報をまとめて検索できるアプリなど、社員待望の便利ツールや、業務での利用を視野に入れた QR コード読み取り機能のプロトタイプ、趣味でやっているポッドキャストの Web ページなどなど、多彩な作品が集まりました。

また、規定部門の発表では、講師・TA から Auth0 を使った認証やインクリメンタルサーチ、Storybook を使ったコンポーネント開発、Recoil を使った状態管理など、課題では扱われなかった少し発展的な技術の紹介がありました。


以上が、Cookpad Online Spring Internship 2021 & Hackarade Remote #2 の開催報告です。 ご参加いただいた皆さま、本当にありがとうございました!

スプリングインターンシップは終わってしまいましたが、クックパッドでは就業型インターンシップを通年で募集しています。

また、サマーインターンシップも例年どおり開催予定です。 興味のある方はぜひご応募ください!


  1. アプリやインテグレーションによる投稿はなく、すべて人間による投稿です。Tech MTG や成果発表では多くの社員を巻き込んで盛り上がりました。

GraphQL Code Generator で TypeScript の型を自動生成する

技術部の外村(@hokaccha)です。

レシピサービスのフロントエンドを Next.js と GraphQL のシステムに置き換えている話 - クックパッド開発者ブログ

という記事を書きましたが、この中で詳しく説明しなかった GraphQL のスキーマやクエリから TypeScript の型定義を自動生成する仕組みについて紹介します。

なお、今回紹介したコードは以下で試せます。

https://github.com/hokaccha/graphql-codegen-example-for-techlife

GraphQL Code Generator を使った型生成

GraphQL のスキーマから TypeScript の型を生成するためのライブラリはいくつかあります。

などが有名どころです。今回はシンプルさや拡張性を考えて GraphQL Code Generator を採用したので、GraphQL Code Generator を使ったコード生成について紹介します。

GraphQL Code Generator 自身は TypeScript 以外の言語に対応していたり、TypeScript の中でも様々な機能のプラグインが提供されており、その中から自分の用途にあったプラグインを組み合わせを選ぶことになります。

今回は自動生成で以下の目的を達成します。

  • クライアントサイドで発行するリクエストとレスポンスに TypeScript の型をつける
  • サーバーサイドの Resolver に TypeScript の型をつける

クライアントサイドとサーバーサイドで利用するプラグインや実装は分断されるのでそれぞれ分けて解説します。

クライアントサイド

まずはクライアントサイドです。使っているプラグインは以下です。

@graphql-codegen/typescript は TypeScript の型生成する場合に必用なプラグインで TypeScript のコードを生成する場合に必用です。@graphql-codegen/typescript-operations は GraphQL のクエリとスキーマを元に TypeScript の型を自動生成します。

設定ファイルは次のようになります。

# codegen.yml
schema: schema.graphql
documents: graphql/**/*.graphql
generates:
  lib/generated/client.ts:
    plugins:
      - typescript
      - typescript-operations

documents には、リクエストするときに発行するクエリを記述したファイルを指定します。スキーマとクエリは次のようになっていたとします。

# schema.graphql
type Recipe {
  id: Int!
  title: String!
  imageUrl: String
}

type Query {
  recipe(id: Int!): Recipe!
}
# graphql/getRecipe.graphql
query getRecipe($id: Int!) {
  recipe(id: $id) {
    id
    title
    imageUrl
  }
}

getRecipe.graphql はアプリケーションから発行するクエリです。アプリケーションのコード内に書くのでなく、ファイルを分けて書いています。

これで graphql-codegen コマンドを実行すると以下のコードが生成されます。

export type Maybe<T> = T | null;
export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
export type MakeOptional<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]?: Maybe<T[SubKey]> };
export type MakeMaybe<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]: Maybe<T[SubKey]> };
/** All built-in and custom scalars, mapped to their actual values */
export type Scalars = {
  ID: string;
  String: string;
  Boolean: boolean;
  Int: number;
  Float: number;
};

export type Recipe = {
  __typename?: 'Recipe';
  id: Scalars['Int'];
  title: Scalars['String'];
  imageUrl?: Maybe<Scalars['String']>;
};

export type Query = {
  __typename?: 'Query';
  recipe: Recipe;
};


export type QueryRecipeArgs = {
  id: Scalars['Int'];
};

export type GetRecipeQueryVariables = Exact<{
  id: Scalars['Int'];
}>;


export type GetRecipeQuery = (
  { __typename?: 'Query' }
  & { recipe: (
    { __typename?: 'Recipe' }
    & Pick<Recipe, 'id' | 'title' | 'imageUrl'>
  ) }
);

このとき、スキーマとクエリに型の不整合があればコード生成のときにエラーになるので、スキーマに違反するようなクエリを書くことができません。例えばクエリを以下のようにしてみます。

query getRecipe($id: Int!) {
  recipe(id: $id) {
    foo
  }
}

これで graphql-codegen を実行するとこのようにエラーになります。

$ npx graphql-codegen
  ✔ Parse configuration
  ❯ Generate outputs
    ❯ Generate lib/generated/client.ts
      ✔ Load GraphQL schemas
      ✔ Load GraphQL documents
      ✖ Generate
        →         at ~/local/src/github.com/hokaccha/graphql-codegen-example-for-techlife/client/graphql/getRecipe.graphql:3:5


 Found 1 error

  ✖ lib/generated/client.ts
    AggregateError:
        GraphQLDocumentError: Cannot query field "foo" on type "Recipe".
    (snip)

これだけでもだいぶ便利ですね。

しかし、まだこれだけだと型が生成されただけでアプリケーション内でリクエストとレスポンスに型を与えることはできていません。それを実現するのが @graphql-codegen/typescript-graphql-request です。これは graphql-request というライブラリをベースにしたクライントを自動で生成してくれます。

他にも react-query をベースにして React hooks として使える @graphql-codegen/typescript-react-query など、いくつか選択肢はありますが、今回は SSR でも同じ用に使えてできるだけシンプルなものという理由で @graphql-codegen/typescript-graphql-request を選択しました。

設定ファイルは pluginstypescript-graphql-request を足すだけです。

# codegen.yml
schema: schema.graphql
documents: graphql/**/*.graphql
generates:
  lib/generated/client.ts:
    plugins:
      - typescript
      - typescript-operations
      - typescript-graphql-request

これでもう一度 graphql-codegen を実行すると、先程の生成したファイルに追加して以下のようなコードが生成されます。

export const GetRecipeDocument = gql`
    query getRecipe($id: Int!) {
  recipe(id: $id) {
    id
    title
    imageUrl
  }
}
    `;

export type SdkFunctionWrapper = <T>(action: () => Promise<T>) => Promise<T>;


const defaultWrapper: SdkFunctionWrapper = sdkFunction => sdkFunction();
export function getSdk(client: GraphQLClient, withWrapper: SdkFunctionWrapper = defaultWrapper) {
  return {
    getRecipe(variables: GetRecipeQueryVariables, requestHeaders?: Dom.RequestInit["headers"]): Promise<GetRecipeQuery> {
      return withWrapper(() => client.request<GetRecipeQuery>(GetRecipeDocument, variables, requestHeaders));
    }
  };
}
export type Sdk = ReturnType<typeof getSdk>;

先程別ファイルにした getRecipe クエリもこの自動生成コードに含まれており、getRecipe() でこのクエリを使ってリクエストし、レスポンスにも型がつきます。アプリケーションからはこのように使います。

import { GraphQLClient } from "graphql-request";
import { useEffect, useState } from "react";
import { getSdk, Recipe } from "./generated/client";

const client = new GraphQLClient("http://localhost:8000/graphql");
const sdk = getSdk(client);

async function getRecipe(id: number) {
  const response = await sdk.getRecipe({ id });
  // const response = await sdk.getRecipe(); // Error: Expected 1-2 arguments, but got 0.

  console.log(response.recipe.id); // id: number
  console.log(response.recipe.title); // title: string
  console.log(response.recipe.imageUrl); // imageUrl?: string

  // @ts-expect-error
  console.log(response.recipe.foo); // Property 'foo' does not exist

  return response.recipe;
}

このように、リクエストとレスポンスに対して自動生成された型がつきます。また、クエリの入力部分は query getRecipe($id: Int!) でしたが、この入力パラメータについても型がつきます。

これでクライアントにおけるリクエストとレスポンスに型がつきました。

サーバーサイド

次にサーバーサイドです。サーバーサイドでは以下のプラグインを使います。

@graphql-codegen/typescript-resolvers が GraphQL サーバーの Resolver の型を自動生成するためのプラグインです。

設定ファイルは次のようにします。

# codegen.yml
schema: schema.graphql
generates:
  lib/generated/resolvers.ts:
    plugins:
      - typescript
      - typescript-resolvers

これで生成された型定義を使って Resolver を次のように書きます。

import { Resolvers } from "./generated/resolvers";

export const resolvers: Resolvers = {
  Query: {
    recipe: async (_parent, args, _context, _info) => {
      return {
        id: args.id,
        title: "recipe title",
        imageUrl: null,
      };
    },
  },
};

これだけです。args に入力値が渡ってきますが、これにはスキーマで指定されている id: Int! の型が渡ってきます。もちろん返り値もチェックされているので返している値がスキーマと整合性が取れていないと型エラーになります。

この Resolver は、graphql-toolsmakeExecutableSchema にそのまま渡せる型として定義されます。ですので、makeExecutableSchema で作った schema をそのまま実行できる Apollo や graphql-express などで実行します。以下は graphql-express の例です。

import fs from "fs";
import express from "express";
import cors from "cors";
import { makeExecutableSchema } from "graphql-tools";
import { graphqlHTTP } from "express-graphql";
import { resolvers } from "./lib/resolvers";

const typeDefs = fs.readFileSync("./schema.graphql", { encoding: "utf8" });

const schema = makeExecutableSchema({
  typeDefs,
  resolvers,
});

const app = express();

app.use(cors());
app.use("/graphql", graphqlHTTP({ schema }));

app.listen(8000, () => {
  console.log("listen: http://localhost:8000");
});

これでサーバーサイドにも型がつきました。

まとめ

GraphQL のスキーマやクエリから TypeScript の型定義やクライアントを自動生成する方法について紹介しました。実際にクックパッドのアプリケーションでもほぼ同じ仕組みで動いています。できるだけシンプルに寄せるため Apollo などは使っておらず必要最低限にしていますが、GraphQL や TypeScript の強力な型付けの恩恵を受けることができて非常に便利です。

また、現状では Apollo や React Query などは組み合わせて使っておらず、キャッシュや hooks 化などは必要に応じてやっていますが、GraphQL Code Generator はそのあたりのプラグインも豊富で変更したいとなったときにプラグインの追加で気軽に構成変更できるのも便利です。

まだまだこのあたりも環境整備も発展途上ですので我こそは最高の環境を作るぞという方、もしくは API 呼び出しに型がなくて疲れてしまい、このような環境で開発してみたくなった方はお気軽にお問い合わせください!

レシピサービスのフロントエンドに CSS in JS を採用した話

こんにちは。技術部クックパッドサービス基盤グループのkaorun343です。我々のチームでは レシピサービスのフロントエンドを Next.js と GraphQL のシステムに置き換えている話 にて紹介したとおり、レシピサービスを Next.js ベースの新システムへと移行しています。今回はこの新システムの CSS の話 です。

背景

クックパッドのレシピサービスを Next.js と TypeScript で置き換えはじめた当初、CSS については Next.js に標準で組み込まれているCSS in JS ライブラリである styled-jsx を使っていました。プロジェクトが大きくなりはじめたタイミングで 「CSS の技術選定を考えなおしてもいいかもしれない」とチームの中で話し合い、改めて技術選定をしました。

技術選定

結論として、本システムでは CSS in JS ライブラリのemotion を採用しました。emotion には css prop を使う @emotion/react(旧 @emotion/core) と、 styled-components ライクな記法が使える @emotion/styled がありますが、css prop を使う @emotion/react を採用しました。記法については React の style prop のような Object Styles ではなく、通常の CSS と同じように書くことができる、タグ付きテンプレートリテラルを用いた String Styles を採用しました。

npmtrends を見ると、一番ダウンロード数が多いのは @emotion/react の昔の名前である @emotion/core となっています(技術選定時は @emotion/core だったので、こちらの名前で比較)。しかし @emotion/styled が依存するライブラリであるため、@emotion/react 単体で使うケースはそう多くありません。したがって @emotion/styled と styled-components が広く使われていることがわかります。

f:id:kaorun343:20210313093352p:plain
CSS in JS ライブラリのダウンロード数

まず、ダウンロード数が多くないにも関わらず @emotion/react による css prop を採用した理由を説明します。

1 点目はカプセル化ができるという点です。CSS はグローバルな名前空間であるため名前が衝突し、他の要素に意図せずスタイルを当ててしまう可能性があります。

<main class="main">
  <section class="section">
    <h2 class="title">タイトル</h2>
  </section>
</main>
/* スタイルA */
.main {
  .title {
    color: green;
  }
}

/* スタイルB */
.section {
  .title {
    color: orange;
  }
}

例えばこの例では、 スタイル A とスタイル B がどちらも .title を指定しているため、 h2.title の文字色は 2 つの記述する順番に依存します(この例の順番だとオレンジ色になります)。 BEM などの命名規則を採用すれば、名前の衝突を避けることができますが、スタイル名の記述が長くなってしまう別の問題があります。 一方で emotion はスタイルごとに一意な ID が割り振られるため、セレクタ名が競合することはありません。したがって上記のような問題を防ぐことができます。

2 点目は静的解析がしやすいという点です。CSS Modulesstyled-jsx では、通常の CSS と同様にセレクタを記述して className にクラス名を書いていくことになります。これらの手法の問題点は、クラス名を間違っていたとしても実行するまで気づくことができないこと、未使用のスタイルを静的解析により検出することができないことです(前者については、通常は Web ブラウザに画面を表示しながら作業するためそれほど大きな問題ではありませんが)。一方 emotion は、ESLint や TypeScript コンパイラといった JavaScript 向けの静的解析ツールにより、未使用の CSS を簡単に検出できます。

3 点目はコードレビューのしやすさです。これは styled-components の記法との比較です。styled-components の記法では、スタイルが当てられた DOM 要素や、スタイルが上書きされた既存のコンポーネントがどれもパスカルケースのコンポーネントとなります。

// styled-components の記法
export const StyledComponent: FC = () => {
  return (
    <Wrapper>
      <IconWrapper>
        <BellIcon />
      </IconWrapper>
      <MainText>お知らせ</MainText>
      <RightText>10</RightText>
    </Wrapper>
  );
};

// css propによる記法
export const CssProp: FC = () => {
  return (
    <div css={wrapperStyle}>
      <div css={iconWrapperStyle}>
        <BellIcon />
      </div>
      <div css={mainTextStyle}>お知らせ</div>
      <div css={rightTextStyle}>10</div>
    </div>
  );
};

この2つのコンポーネントを見ていただければわかると思いますが、パスカルケースの JSX タグを見たときに、それが「機能を持つコンポーネント」なのか、もしくは「スタイルが当てられたコンポーネント」なのか、ひと目で判断しにくいです。この理由から styled-components の記法は不採用となりました。

次に String Styles を採用した理由を説明します。

Object Styles では font-size などのケバブケースの CSS プロパティを fontSize のようにキャメルケースで記述しなければなりません。一方で String Styles は通常の CSS と同じように記述できます。通常の CSS の記述に慣れ親しんでいるメンバーが多いため、読みやすさや書きやすさの点から String Styles を採用しました。

const objectStyle = css({
  fontSize: "30px",
  color: "blue",
});

const stringStyle = css`
  font-size: 30px;
  color: blue;
`;

emotion の機能

emotion は stylis という CSS ライブラリが基盤となっており、以下の機能を提供してくれます。

  • ベンダープレフィックスの自動付与
  • セレクタのネスト
  • メディアクエリのサポート
  • テーマ機能のサポート

CSS を返す関数を定義することで、動的に スタイルを生成できます。

const getTextareaStyle = (isValid: boolean) => css`
  border: solid 1px ${isValid ? "green" : "red"};
`;

また別の CSS オブジェクトを元に新しいスタイルを定義できます。

const buttonBaseStyle = css`
  width: 100%;
  color: red;
`;

/**
 * const buttonSpecialStyle = css`
 *   width: 100%;
 *   color: red;
 *   font-size: 16px;
 * `;
 */
const buttonSpecialStyle = css`
  ${buttonBaseStyle}
  font-size: 16px;
`;

それ以外にも @emotion/babel-plugin により Babel のトランスパイル時にコードの最適化をおこない、不要な改行やインデントの削除などをおこなうことができます。

styled-jsx から emotion への置き換え

この 2 つのライブラリは共存させることが可能だったため少しずつ emotion へ置き換えることができました。移行の過程で CSS の記述を大きく変える必要がなく、プロジェクトが始まって間もない頃で変更箇所が少なかったことから、数回のプルリクエストで移行を完了しました。

emotion を用いた React コンポーネントの書き方

こちらがコンポーネントのコード例です。

import { css } from "@emotion/react";
import { FC, useEffect } from "react";
import { StepListItem } from "./StepListItem";
import { Step } from "./Step";

type Props = {
  steps: readonly Step[];
};

export const StepList: FC<Props> = ({ steps }) => {
  useEffect(() => {
    //
  }, []);

  return (
    <section css={rootStyle}>
      <h2 css={sectionTitleStyle}>手順</h2>
      {stpes.map((step) => (
        <StepListItem key={step.id} step={step} />
      ))}
    </section>
  );
};

const rootStyle = css`
  padding: 8px;
`;

const sectionTitleStyle = css`
  font-size: 16px;
  &:hover {
    text-decoratoion: underline;
  }
`;

CSS in JS では基本的に 1 つのファイルにコンポーネントの HTML、CSS、JavaScript をすべて詰め込みますが、それらを記述する順番が読みやすさに強く影響します。そこで本システムでは CSS をファイルの一番下に配置しました。

CSS は JavaScript よりも HTML (JSX) と密接に関連しています。また React の関数コンポーネントは関数の前半に React フックを書き、後半に JSX タグを書く構成がほとんどです。もし CSS の定義が関数コンポーネントの前に置かれていると、 React フックの行数が増えるほど CSS と JSX が離れてしまい、コードが見にくくなってしまいます。その点今回の配置では、CSS の変数の使用箇所が定義よりも前にくる違和感はありますが、 CSS と JSX が近い位置に配置されてコードが読みやすくなりました。 Vue.js のスタイルガイドにおいても <style></style> を一番下に配置するように推奨しています。

stylelint の導入

CSS の宣言そのものに対する静的解析ツールの stylelint もあわせて導入しました。存在しないプロパティや誤った値の指定を検出してくれます。stylelint 単体では emotion のコードを検証してくれないため、emotion の CSS 宣言をチェックするために、 stylelint-processor-styled-components を利用しています。こちらは名前に styled-components とある通り、もとより styled-components 向けのライブラリです。しかしemotion に対しても問題なく使えます。

Emotion には Next.js の SSR 環境 に起因する問題がありました。これは SSR 環境において:nth-child などの *-childが使えないという問題です( https://github.com/emotion-js/emotion/issues/1178 )。SSR 環境では <style></style> タグが途中に挿入される場合があり、正しくスタイルを当てられない可能性があるためです。

そこで、 *-child の代わりに *-of-type を使うように stylelint にプラグインを追加することで対処しました。

const stylelint = require("stylelint");

const ruleName = "local/unsafe-pseudo-classes";
const messages = stylelint.utils.ruleMessages(ruleName, {
  expected: (unsafePseudoClass) => {
    return `The pseudo class "${unsafePseudoClass}" is potentially unsafe when doing server-side rendering. Try changing it to "${unsafePseudoClass.replace(
      "-child",
      "-of-type"
    )}".`;
  },
});

module.exports = stylelint.createPlugin(ruleName, function (primaryOption) {
  return function (postcssRoot, postcssResult) {
    const validOptions = stylelint.utils.validateOptions(
      postcssResult,
      ruleName,
      {
        actual: primaryOption,
        possible: [true],
      }
    );

    if (!validOptions) {
      return;
    }

    postcssRoot.walkRules((rule) => {
      const unsafePseudoClass = /:(?:first|nth|nth-last)-child/g.exec(
        rule.selector
      )?.[0];

      if (unsafePseudoClass) {
        stylelint.utils.report({
          message: messages.expected(unsafePseudoClass),
          node: rule,
          result: postcssResult,
          ruleName,
        });
      }
    });
  };
});

module.exports.ruleName = ruleName;
module.exports.messages = messages;

テストについて

本システムでは JestReact Testing Library によるコンポーネントのテストをおこなっており、スナップショットテストも活用しています。

@emotion/babel-preset-css-prop を利用することで、CSS の記述を展開してくれます。そのためスナップショットで CSS に変更がないか確認できます。以下にコンポーネントのスナップショットテストの例を示します。

/** @jest-environment jsdom */
/** @jsx jsx */
import { css, jsx } from "@emotion/react";
import { render } from "@testing-library/react";
import { FC } from "react";

const MyComponent: FC = () => {
  return (
    <div>
      <button type="button" css={buttonBaseStyle}>
        Base Style
      </button>
      <button type="button" css={buttonSpecialStyle}>
        Special Style
      </button>
    </div>
  );
};

const buttonBaseStyle = css`
  width: 100%;
  color: red;
`;

const buttonSpecialStyle = css`
  ${buttonBaseStyle}
  font-size: 16px;
`;

it("renders component", () => {
  const { container } = render(<MyComponent />);
  expect(container.firstChild).toMatchInlineSnapshot(`
    .emotion-0 {
      width: 100%;
      color: red;
    }

    .emotion-1 {
      width: 100%;
      color: red;
      font-size: 16px;
    }

    <div>
      <button
        class="emotion-0"
        type="button"
      >
        Base Style
      </button>
      <button
        class="emotion-1"
        type="button"
      >
        Special Style
      </button>
    </div>
  `);
});

将来的には画像回帰テストも導入して、より堅牢なシステムの構築を検討しています。

さいごに

今回はレシピサービスの新システムにおける CSS の話を紹介しました。クックパッドではこれからもモダンな技術によるレシピサービスの刷新を進めていきます。この取り組みを一緒に進めてくれる仲間を募集していますので、興味のある方はぜひご気軽にご連絡ください。

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/