Open5

SolidJSのコンポーネントライブラリ実装

yunayuna

Solidコンポーネントライブラリ実装の覚書き

Solid用のコンポーネントライブラリ実装に当たって、
Vueのコンポーネントと違い、
SolidのコンポーネントはClient, SSRで切り替えができないため、
ハイドレーションのように初期表示もしつつ、描画のりアクティビティを保つことができない。
そこで、SSR版で初期表示を行い(SEO担保やSkeleton表示、チラツキ防止などのため)、
描画後にClient版に切り替えてちゃんとリアクティビティが保持されるようにした。

また、Client版を、アプリ側でライブラリをimportするときも、
SolidStartでそのままimportすると、
Error: Client-only API called on the server side. Run client-only code in onMount, or conditionally run client-only component with .
このエラーが出るので、
ライブラリ側で、lazyに読み込める仕組みを作った。

Vueのときに考えなくてよかったことなど、色々とかなり苦労したので、作業メモをしておきます。

##ライブラリ(Vue/Solid混合型。今回の記事はSolidの部分です)
https://github.com/generalworksinc/gw-front-common/tree/v0.0.43

yunayuna

※AIにやったことをまとめてもらった

Solid コンポーネントライブラリ実装メモ

以下は、Solid コンポーネントライブラリを安全に公開・利用するための注意点と実践的な対策を、わかりやすくまとめたチェックリスト/テンプレです。

  • 目的: SSR 環境(build 時の server)とクライアント(ブラウザ)でリアクティビティを壊さず使えるライブラリ設計

  • 1) エントリ設計(exports)を明確に

    • UI(ブラウザ実装)は常にブラウザ向けエントリ(index.js)を指すようにする。
    • 明示的にSSR版をimpotできるよう、エントリーをわけておく。
    • サーバ専用ロジックは ./solid/server/* のように別サブパスで管理。
    • package.json の exports はブラウザ版と node/server を正しく分ける。

    例(components をブラウザ版に固定):

    "./solid/components": {
      "types": "./dist/solid/components/index.d.ts",
      "browser": "./dist/solid/components/index.js",
      "default": "./dist/solid/components/index.js"
    },
    "./solid/components_ssr": {
        "types": "./dist/solid/components_ssr/index.d.ts",
        "solid": "./dist/solid/components_ssr/server.js",
        "browser": "./dist/solid/components_ssr/index.js",
        "import": "./dist/solid/components_ssr/server.js",
        "default": "./dist/solid/components_ssr/server.js"
    },
    
  • 3) Solid の単一インスタンス保証

    • ライブラリとアプリで solid-js が重複するとシグナルが別インスタンス化され、リアクティブが効かないことがある。
    • 消費側では Vite の resolve.dedupe = ['solid-js'] を設定する。
    • パッケージが node_modules に重複していないか npm ls solid-js で確認。
  • 4) client-only コンポーネントの安全な公開

    • 対策: SSR 安全な「薄いラッパー」を公開し、実体は onMount / 動的 import (import('./client')) で読み込む。

    簡単なラッパー例:

    // components_clientonly.tsx
    import { createSignal, onMount } from 'solid-js';
    import { Dynamic } from 'solid-js/web';
    const gw = () => import('./components_real');
    function makeLazy(name){
      return (props)=>{
        const [C,setC]=createSignal(null);
        onMount(async()=>{ const m=await gw(); setC(()=>m[name]); });
        return <Dynamic component={C()} {...props} />;
      }
    }
    export const Notifications = makeLazy('Notifications');
    
  • 6) props と動的 import の注意

    • ラッパー経由で動的 import をするときは、<Dynamic component={Loaded} {...props} /> のように props をそのまま渡す。
    • レンダ関数などで props を保持する閉包を作ると、意図しない参照喪失が起きることがある。

yunayuna
tsup.config.ts
import { defineConfig } from 'tsup';
import { generateTsupOptions, parsePresetOptions } from 'tsup-preset-solid';

export default defineConfig((_opts) => {
	const options = parsePresetOptions({
		entries: [
			{
				name: 'solid/mod',
				entry: 'solid/mod.ts',
				// dev_entry: true,
				server_entry: true,
			},
			{
				name: 'solid/components',
				entry: 'solid/components.ts',
				// dev_entry: true,
				// server_entry: true,
			},
			{
				name: 'solid/components_ssr',
				entry: 'solid/components_ssr.ts',
				// dev_entry: true,
				server_entry: true,
			},
		],
		// keep JSX; declaration build must read tsconfig.solid.json via tsup (handled by preset)
	});
	const arr = generateTsupOptions(options);
	return arr.map((cfg) => {
		return {
			...cfg,
			// Use Solid tsconfig so TS knows JSX settings while emitting d.ts
			tsconfig: 'tsconfig.solid.json',
		};
	});
});
yunayuna

client版のエントリーポイントとして用意する

components.ts
export * from './components_clientonly';

SSR版のエントリーポイントとして用意する(※初期表示しなくてもいいなら、これは使わなくていい)

components_ssr.ts
// Actual components (browser-only build)
export * from './features/notification/components/Notifications';

SSRで直接importしても問題ないよう、lazyでローディングさせるラッパー

components_clientonly.tsx
/** @jsxImportSource solid-js */
import { createSignal, type JSX, onMount } from 'solid-js';
import { Dynamic } from 'solid-js/web';

// SSR-safe wrappers without depending on @solidjs/start
const gwMod = () => import('./components_ssr');

type AnyComponent = (props: any) => JSX.Element;

function makeLazy(key: string) {
	return function LazyComponent(props: any): JSX.Element {
		const [Component, setComponent] = createSignal<AnyComponent | null>(null);
		onMount(async () => {
			const mod: any = await gwMod();
			setComponent(() => mod[key] as AnyComponent);
		});
		return <Dynamic component={Component() as any} {...props} />;
	};
}

export const Notifications = makeLazy('Notifications');
export const Modal = makeLazy('Modal');
export const Loading = makeLazy('Loading');
yunayuna

アプリ側の実装

前提として、上記のラッパーが無く、直接コンポーネントをimportする場合、
このように書かないと開発中(SSRで動く)、solidstartのビルド時に、「SSRの処理中にClient特有のAPI(document,windowなど)が呼ばれた」とエラーになる。

毎回書くのが面倒なので、ラッパーを導入して、簡易的にかけるようにしました。

xxx.tsx
import { clientOnly } from '@solidjs/start';
const Notifications = clientOnly(() =>
  import("@library/solid/components")
    .then(m => ({ default: m.Notifications }))
);

...省略...            
              <Notifications store={notificationStore} />

初期表示不要・Clientのみで動けばOKの場合

xxx.tsx
import { Notifications} from '@library/solid/components';

...省略...            
              <Notifications store={notificationStore} />

切り分ける場合

xxx.tsx
import { Notifications} from '@library/solid/components';
import { Notifications as NotificationsSSR} from '@library/solid/components_ssr';

...省略...

            <Show fallback={
              <Notifications store={notificationStore} />
            } when={isServer} >
              <NotificationsSSR store={notificationStore} />
            </Show>