公式サンプルの Hydrogen アプリを TypeScript に書き直して Vitest を導入したときに気になったことや躓いたところ
Hydrogen アプリをさわる機会が出てきたので頃合いを見計らって公式サンプルを TypeScript に書き直してみました。その際に気になったことや躓いたところ、メモしておきたいことなどを書いていきます。
今回作ったアプリは以下のリポジトリに置いておきました。
はじめに
参考にした公式サンプルは「Build a Hydrogen storefront」です。Hydrogen を公式に沿って構築してくと、当たり障りのない手触りの良さそうな構成(主観ではありますが)が出来上がります。
以下が最終的なアプリの構成です。
- 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 になります。
そのため、ファイル名や型は脳内補完したり、手動で当てていく必要があります。
DataFunctionArgs
loader の引数の型は Hydrogen というか Remix のお作法の一つである loader
という、サーバー側で動作するデータ取得機能のようなもの。その型は @shopify/remix-oxygen
に定義されています。
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});
}
typeof loader
で取得できる
loader の返却値の型は loader 関数の返却値の型をコンポーネント側で取得するには、useLoaderData
の型指定で <typeof loader>
と定義することで、期待する返却値の型が取得できます。
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>();
...
V2_MetaArgs
meta の引数の型は meta 関数の引数の型は @shopify/remix-oxygen
に V2_MetaArgs
として定義されています。
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
に定義されている型を付与していく作業になります。
@shopify/hydrogen/storefront-api-types
を参考に
カート用コンポーネントの型も カートページ関連の型もそれぞれ @shopify/hydrogen/storefront-api-types
から取得して設定します。
import type {
CartCost,
BaseCartLineConnection,
CartLine,
ComponentizableCartLine,
} from '@shopify/hydrogen/storefront-api-types';
root.tsx が "root route"
Hydrogen デフォルトでは app
ディレクトリ配下が以下のような構成になっています。この root.tsx
がレイアウトのトップルートとして機能します。アプリケーション全体で利用したい meta や links などはここで定義します。
app/
├── routes/
└── root.tsx
_index.tsx がトップページになる
app/routes/_index.tsx
ファイルが、URLで言うところの https://hydrogen-hello-world.com
で表示されるページになります。
entry.client / entry.server について
entry.clien
はブラウザのバンドル時のエントリーポイントになります。フロント側のハイドレーションを制御したり、他のクライアント側のコードをここで初期化することもできます。
一方の entry.server
は、HTTPステータス・ヘッダー、HTMLを含むレスポンスを作成・カスタマイズが可能で、マークアップが生成されてクライアントに送信される方法を完全に制御できます。
この2つはけっこう大事そう。
Storefront API とのやり取り
Storefront API とのやり取りは基本「hydrogen-react」という Hydorgen に予めパッケージされたライブラリを利用します。このライブラリは主に、Shopify でよく使うであろうコンポーネント(ボタンとか)や再利用可能な関数、便利なユーティリティなどを提供しています。
代表的なのは Storefront のクライアントの初期化などでしょうか。
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 で対応しました。
<NonceProvider>
<RemixServer context={remixContext} url={request.url} />
</NonceProvider>
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
というコンポーネントにまとめられています。このコンポーネントはカートを操作するアクションを管理しているものです。
各アクションに応じて、HydrogenCart
を呼び出す仕組みです。
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 の機能になります。
export async function loader() {
throw new Response('Not found', {status: 404});
}
// TODO: なんか404っぽいものを返す
export default function Component() {
return null;
}
defer でデータ取得をいい感じに
defer
は Remix v1.11 で追加された機能です。
この機能を使うと、未解決の 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
で利用されています。
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),
});
}
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:
"ミニ"ってかわいい。
$ 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 に予めインストールされています。
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
module.exports = {
plugins: {
autoprefixer: {},
},
};
invariant、ハイッテマセン
カートページの action
で利用されている invariant
というライブラリですが、公式サンプルではこのライブラリのインストール手順の記載がありません。そのためこのライブラリについては個別でインストールする必要があります。
npm install tiny-invariant
Vitest のインストールと設定
ユニットテストライブラリ「Vitest」の導入方法です。
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
を以下のように更新します。
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()],
});
package.json
にテスト用のスクリプトを追加します。
"scripts": {
...
"test": "vitest"
},
テスト用ファイルを作成します。
$ mkdir tests/routes/ && touch 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 開発関連の記事があまり多くない印象なので今回書いてみました。記事についてのツッコミなど大歓迎です。
今回の作成したアプリは必要最低限のもので構成されています。今後ここからアプリを発展させて遊んでみる場合は以下のようなものが挙げられそうです。
- 実際の商品や注文データを使用してみる
- Shopify Admin API を使ってみる
- GraphQL Codegen の導入
このあたりはまたの機会に。
こちらからは以上です。
追記
公式の demo-store テンプレートがかなり参考になりそうだったので共有します。型の適用などはこれを参考にしておけばよかったと今更ながら後悔してます。
-
"じゃわめぐ" は津軽弁で「血が騒ぐ、震える、ぞくぞくする、心騒ぐ」などの意味 ↩︎
Discussion