🚰

公式サンプルの Hydrogen アプリを TypeScript に書き直して Vitest を導入したときに気になったことや躓いたところ

2023/09/25に公開

Hydrogen アプリをさわる機会が出てきたので頃合いを見計らって公式サンプルを TypeScript に書き直してみました。その際に気になったことや躓いたところ、メモしておきたいことなどを書いていきます。

https://hydrogen.shopify.dev/

今回作ったアプリは以下のリポジトリに置いておきました。

https://github.com/maecha/hydrogen-hello-world

はじめに

参考にした公式サンプルは「Build a Hydrogen storefront」です。Hydrogen を公式に沿って構築してくと、当たり障りのない手触りの良さそうな構成(主観ではありますが)が出来上がります。

https://shopify.dev/docs/custom-storefronts/hydrogen/building

以下が最終的なアプリの構成です。

  • Hydrogen (2023.7.7)
  • Remix
  • Shopify CLI
  • ESLint
  • Prettier
  • GraphQL generator
  • TypeScript
  • PostCSS
  • Headless UI
  • Vite
  • Vitest

こんな感じの構成です。この中で Vitest のみ個別でインストールしています。残りは Hydrogen のプロジェクト作成の段階で自動的にインストールされます(ヤッタネ!)。

また、今回のアプリの実行環境は以下になります。

$ node -v
v18.16.0

$ npm -v
9.5.1

公式サンプルでつくるアプリ

公式サンプルのゴールは、「商品カテゴリー(サンプルではコレクションと呼称している)ごとの一覧と商品の詳細ページ、そしてカートへの追加・削除、そしてカート情報の一覧」というよくあるECサイトの構成を作るというものです。

トップページ
トップページ

コレクション一覧ページ
コレクション詳細ページ

商品ページ
商品ページ

商品ページ カートモーダル(開)
商品ページ カートモーダル(開)

カートページ
カートページ

書いていきます

それでは気になったことや躓いたところ、メモしておきたいことなどを書いていきます。

公式サンプルを TypeScript 化する

基本的に公式サンプルで利用される言語は JavaScript になります。

そのため、ファイル名や型は脳内補完したり、手動で当てていく必要があります。

loader の引数の型は DataFunctionArgs

Hydrogen というか Remix のお作法の一つである loader という、サーバー側で動作するデータ取得機能のようなもの。その型は @shopify/remix-oxygen に定義されています。

app/routes/cart.tsx
import {json, DataFunctionArgs} from '@shopify/remix-oxygen';

export async function loader({context}: DataFunctionArgs) {
  const {cart} = context;
  const cartData = await cart.get();
  return json({cart: cartData});
}

https://remix.run/docs/en/main/route/loader

loader の返却値の型は typeof loader で取得できる

loader 関数の返却値の型をコンポーネント側で取得するには、useLoaderData の型指定で <typeof loader> と定義することで、期待する返却値の型が取得できます。

app/routes/cart.tsx
import {useLoaderData} from '@remix-run/react';
import {json, DataFunctionArgs} from '@shopify/remix-oxygen';

export async function loader({context}: DataFunctionArgs) {
  const {cart} = context;
  const cartData = await cart.get();
  return json({cart: cartData});
}

export default function Cart() {
  const {cart} = useLoaderData<typeof loader>();
...

meta の引数の型は V2_MetaArgs

meta 関数の引数の型は @shopify/remix-oxygenV2_MetaArgs として定義されています。

app/routes/collections.$handle.tsx
import {V2_MetaArgs} from '@shopify/remix-oxygen';

export function meta({data}: V2_MetaArgs) {
  return [
    {title: data?.collection?.title ?? 'Collection'},
    {description: data?.collection?.description},
  ];
}

商品ページ関連の型は @shopify/hydrogen/storefront-api-types を参考に

今回の実装で特に大変だった商品ページの型。コンポーネントも複数使っていたり、型の定義で交差型を利用したので苦労しました。

ここでは関連するファイルをリポジトリの URL を載せます。

基本的には @shopify/hydrogen/storefront-api-types に定義されている型を付与していく作業になります。

https://github.com/maecha/hydrogen-hello-world/blob/main/app/routes/products.%24handle.tsx

https://github.com/maecha/hydrogen-hello-world/blob/main/app/components/ProductCard.tsx

https://github.com/maecha/hydrogen-hello-world/blob/main/app/components/ProductGrid.tsx

https://github.com/maecha/hydrogen-hello-world/blob/main/app/components/ProductGrid.tsx

カート用コンポーネントの型も @shopify/hydrogen/storefront-api-types を参考に

カートページ関連の型もそれぞれ @shopify/hydrogen/storefront-api-types から取得して設定します。

app/components/Cart.tsx
import type {
  CartCost,
  BaseCartLineConnection,
  CartLine,
  ComponentizableCartLine,
} from '@shopify/hydrogen/storefront-api-types';

root.tsx が "root route"

Hydrogen デフォルトでは app ディレクトリ配下が以下のような構成になっています。この root.tsx がレイアウトのトップルートとして機能します。アプリケーション全体で利用したい metalinks などはここで定義します。

app/
├── routes/
└── root.tsx

https://remix.run/docs/en/main/file-conventions/routes

_index.tsx がトップページになる

app/routes/_index.tsx ファイルが、URLで言うところの https://hydrogen-hello-world.com で表示されるページになります。

entry.client / entry.server について

entry.clien はブラウザのバンドル時のエントリーポイントになります。フロント側のハイドレーションを制御したり、他のクライアント側のコードをここで初期化することもできます。

一方の entry.server は、HTTPステータス・ヘッダー、HTMLを含むレスポンスを作成・カスタマイズが可能で、マークアップが生成されてクライアントに送信される方法を完全に制御できます。

この2つはけっこう大事そう。

https://remix.run/docs/en/main/file-conventions/entry.client
https://remix.run/docs/en/main/file-conventions/entry.server

Storefront API とのやり取り

Storefront API とのやり取りは基本「hydrogen-react」という Hydorgen に予めパッケージされたライブラリを利用します。このライブラリは主に、Shopify でよく使うであろうコンポーネント(ボタンとか)や再利用可能な関数、便利なユーティリティなどを提供しています。

https://shopify.dev/docs/custom-storefronts/hydrogen-react

代表的なのは Storefront のクライアントの初期化などでしょうか。

server.ts
const {storefront} = createStorefrontClient({
  cache,
  waitUntil,
  i18n: {language: 'JA', country: 'JP'},
  publicStorefrontToken: env.PUBLIC_STOREFRONT_API_TOKEN,
  privateStorefrontToken: env.PRIVATE_STOREFRONT_API_TOKEN,
  storeDomain: env.PUBLIC_STORE_DOMAIN,
  storefrontId: env.PUBLIC_STOREFRONT_ID,
  storefrontHeaders: getStorefrontHeaders(request),
});

Content Security Policy (CSP) の問題が原因で、インラインスクリプトの実行が拒否される

ブラウザのコンソールログに以下のエラーが出力されました。

Refused to execute inline script because it violates the following Content Security Policy directive: 
"default-src 'self' 'nonce-xxxxxxxxxxxxxxxxxx' https://cdn.shopify.com https://shopify.com". 
Either the 'unsafe-inline' keyword, a hash ('sha256-xxxxxxxxxxxxxxxxxxxxxxx'), 
or a nonce ('nonce-...') is required to enable inline execution. 
Note also that 'script-src' was not explicitly set, so 'default-src' is used as a fallback.

entry.server.tsx 側で Nonce を渡していたけど root.tsx でうまく取得できていなかったのが原因でした。上記の「hydrogen-react」の Hooks で提供されている useNonce で対応しました。

https://shopify.dev/docs/custom-storefronts/hydrogen/content-security-policy

entry.server.tsx
<NonceProvider>
  <RemixServer context={remixContext} url={request.url} />
</NonceProvider>
root.tsx
import {useNonce} from '@shopify/hydrogen';

export default function App() {
  const data = useLoaderData<typeof loader>();
  const nonce = useNonce();
  const {shop} = data.layout;

  return (
    <html lang="ja">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width,initial-scale=1" />
        <Seo />
        <Meta />
        <Links />
      </head>
      <body>
        <Layout title={shop.name}>
          <Outlet />
        </Layout>
        <ScrollRestoration nonce={nonce} />
        <Scripts nonce={nonce} />
      </body>
    </html>
  );
}

ちなみにこのエラーは Headless UI の動作と関連があるので、これは対応しておいた方が良さそうな項目です。

カートのアクションは CartForm で

ECサイトで大事な機能の一つであるカート機能、その機能は CartForm というコンポーネントにまとめられています。このコンポーネントはカートを操作するアクションを管理しているものです。

https://shopify.dev/docs/api/hydrogen/2023-07/components/cartform

各アクションに応じて、HydrogenCart を呼び出す仕組みです。

https://shopify.dev/docs/custom-storefronts/hydrogen/building/cart#step-1-create-a-cart-instance-with-createcarthandler

app/routes/cart.tsx
import {CartForm} from '@shopify/hydrogen';
import {json, DataFunctionArgs} from '@shopify/remix-oxygen';

export async function action({request, context}: DataFunctionArgs) {
  const {cart} = context;

  const formData = await request.formData();
  const {action, inputs} = CartForm.getFormInput(formData);

  let result;

  switch (action) {
    case CartForm.ACTIONS.LinesAdd:
      result = await cart.addLines(inputs.lines);
      break;
    case CartForm.ACTIONS.LinesUpdate:
      result = await cart.updateLines(inputs.lines);
      break;
    case CartForm.ACTIONS.LinesRemove:
      result = await cart.removeLines(inputs.lineIds);
      break;
    default:
      invariant(false, `${action} cart action is not defined`);
  }

  if (!result) {
    return json(result, {status: 200});
  }

  // ミューテーションの後でカートIDが変更されるかもしれないので毎回更新する
  const headers = cart.setCartId(result.cart.id);

  return json(result, {status: 200, headers});
}

$.tsx で 404 制御

最新の Hydrogen での404ページ制御は "Splat Routes" に従っています。これは Remix の機能になります。

$.tsx
export async function loader() {
  throw new Response('Not found', {status: 404});
}

// TODO: なんか404っぽいものを返す
export default function Component() {
  return null;
}

https://remix.run/docs/en/1.19.3/file-conventions/routes-files#splat-routes

defer でデータ取得をいい感じに

deferRemix v1.11 で追加された機能です。

https://remix.run/docs/en/main/guides/streaming

この機能を使うと、未解決の Promise を loader から返すことができ、クライアント側ではプロミスが解決されるのを待たずにサーバ側でレンダリングされます。そして Promise が解決されたときにクライアントが新しいデータで再レンダリングされます。

React Streaming の Remix 版のようなものでしょうか。

Remix Streaming を斜め読み。取得するデータの重要度に応じて loader の中で defer / json を切り替えながら、重い処理だったら Suspense で待ってもらったり。いいでば。じゃわめぐ。

via: https://bsky.app/profile/maechan.bsky.social/post/3k7sqqr6oes2t

じゃわめぐ[1]

今回の公式サンプルでは root.tsx でこの機能が使われていて、defer 対象のデータは app/components/Layout.tsx で利用されています。

app/root.tsx
import {defer} from '@shopify/remix-oxygen';

export async function loader({context}: DataFunctionArgs) {
  const {cart} = context;

  return defer({
    cart: cart.get(), // 未解決の Promise のままクライアントへ渡す
    layout: await context.storefront.query<{shop: Shop}>(LAYOUT_QUERY),
  });
}
app/components/Layout.tsx
import {Suspense} from 'react';
import {Await} from '@remix-run/react';
import type {Cart} from '@shopify/hydrogen/storefront-api-types';

type CartDrawerProps = {
  cart: Cart;
  close: () => void;
};

function CartDrawer({cart, close}: CartDrawerProps) {
  // Promise が解決されたときにクライアントが新しいデータで再レンダリングされる
  return (
    <Suspense>
      <Await resolve={cart}>
        {(data) =>
	...

MiniOxygen ってなに?

プロジェクトを作り、アプリを起動するとコマンドラインのメッセージの中に「MiniOxygen development server running.」と出力されます。この MiniOxygen というのは Hydrogen のローカルサーバーの名前のようです。

Hydrogen includes the MiniOxygen development server to run your app locally. MiniOxygen provides close parity with Shopify’s Oxygen runtime. To start your local development server, run the following command:

https://shopify.dev/docs/custom-storefronts/hydrogen/getting-started/quickstart#start-a-development-server

"ミニ"ってかわいい。

$ cd hydrogen-hello-world && npm run dev

> hello-world@0.0.0 dev
> shopify hydrogen dev

Environment variables injected into MiniOxygen:

SESSION_SECRET        from local .env
PUBLIC_STORE_DOMAIN   from local .env

╭─ success ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│                                                                                                                                                  │
│  Initial build: 408ms                                                                                                                            │
│  MiniOxygen development server running.                                                                                                          │
│                                                                                                                                                  │
│  View Hydrogen app: http://localhost:3000                                                                                                        │
│  View GraphiQL API browser: http://localhost:3000/graphiql                                                                                       │
│  View server-side network requests: http://localhost:3000/debug-network                                                                          │
│                                                                                                                                                  │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

PostCSS、ハイッテマス

インストールされたライブラリを追うまで気づかなかったんですが、PostCSS は Hydrogen に予めインストールされています。

https://remix.run/docs/en/main/styling/postcss

PostCSS is a popular tool with a rich plugin ecosystem, commonly used to prefix CSS for older browsers, transpile future CSS syntax, inline images, lint your styles and more. When a PostCSS config is detected, Remix will automatically run PostCSS across all CSS in your project.

よく使うであろう、ベンダープレフィックスを最適化してくれる autoprefixer をインストールして設定しておきました。

$ npm install -D autoprefixer
$ touch postcss.config.cjs 
postcss.config.cjs
module.exports = {
  plugins: {
    autoprefixer: {},
  },
};

invariant、ハイッテマセン

カートページの action で利用されている invariant というライブラリですが、公式サンプルではこのライブラリのインストール手順の記載がありません。そのためこのライブラリについては個別でインストールする必要があります。

npm install tiny-invariant

https://github.com/alexreardon/tiny-invariant

Vitest のインストールと設定

ユニットテストライブラリ「Vitest」の導入方法です。

https://vitest.dev/

Vitest と必要な関連ライブラリをインストールします。実行環境は happy-dom を選択しました。

$ npm install -D vitest vite-tsconfig-paths
$ npm install @vitejs/plugin-react
$ touch vitest.config.ts  
$ npm install happy-dom

vitest.config.ts を以下のように更新します。

vitest.config.ts
import * as path from 'path';
import * as VitestConfig from 'vitest/config';
import tsconfigPaths from 'vite-tsconfig-paths';
import react from '@vitejs/plugin-react';

export default VitestConfig.defineConfig({
  test: {
    environment: 'happy-dom',
    globals: true,
    includeSource: ['./app/**/*.{ts,tsx}'],
    exclude: ['node_modules', 'e2e'],
  },
  resolve: {
    alias: {
      // eslint-disable-next-line @typescript-eslint/naming-convention
      '~': path.resolve(__dirname, 'app'),
    },
  },
  plugins: [react(), tsconfigPaths()],
});

https://github.com/remix-run/blues-stack/commit/41cf1d86bb27f0a4c9f926a1139772c1b1d1c271#diff-2ee894bf23aa44ff4ce12a3da8af19ab20180474ebf2176dbe5f2eea3f96dc92

package.json にテスト用のスクリプトを追加します。

package.json
 "scripts": {
    ...
    "test": "vitest"
  },

テスト用ファイルを作成します。

$ mkdir tests/routes/ && touch tests/routes/root.test.tsx
tests/routes/root.test.tsx
import {expect, test} from 'vitest';

test('Sample Test', () => {
  expect(1 + 2).toBe(3);
});

そしてテストの実行。

$ npm run test

> hydrogen-hello-world@0.0.0 test
> vitest

 DEV  v0.34.4 /Users/maechan/Dev/shopify/hydrogen-hello-world

 ✓ tests/routes/root.test.tsx (1)
   ✓ Sample Test

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  16:12:15
   Duration  244ms (transform 20ms, setup 0ms, collect 11ms, tests 1ms, environment 79ms, prepare 50ms)

 PASS  Waiting for file changes...
       press h to show help, press q to quit

おわり

書き終えてみて、Hydrogen の公式サンプルを一通りやってみて思うところを書きなぐった内容になってしまいましたが、Shopify 開発関連の記事があまり多くない印象なので今回書いてみました。記事についてのツッコミなど大歓迎です。

今回の作成したアプリは必要最低限のもので構成されています。今後ここからアプリを発展させて遊んでみる場合は以下のようなものが挙げられそうです。

このあたりはまたの機会に。

こちらからは以上です。

追記

公式の demo-store テンプレートがかなり参考になりそうだったので共有します。型の適用などはこれを参考にしておけばよかったと今更ながら後悔してます。

https://github.com/Shopify/hydrogen/tree/2023-04/templates/demo-store

脚注
  1. "じゃわめぐ" は津軽弁で「血が騒ぐ、震える、ぞくぞくする、心騒ぐ」などの意味 ↩︎

Discussion