Freshで記事読み込み中にローディングを表示する

作成日
更新日

やりたいこと

Deno Fresh のパーシャルを使っているので、それに合わせてローディングして欲しい。

  • @preact/signals を使えば良さそう?
    • Handlerの中で変えてもその変更は伝わらない。

コレはダメ

サーバーサイドでsignalの値を変えても変更はつたわらない。

// routes/api/start-loading.ts
import { isLoading } from "../../signals/loading.ts";

export const handler: Handlers = {
  GET() {
    isLoading.value = true; // ← この変更はクライアントには伝わらない
    return new Response("OK");
  },
};

export default function PostPage({ data }: PageProps) {
  const { post } = data;
  isLoading.value = true;
  return (
    <>
    </>
    )
}

islands のなかの useEffectで変える必要がある。

これも isLoading.value は常にfalseになってしまう。islandsのなかじゃないからかな。

export default function PostPage({ data }: PageProps) {
  const { post } = data;
  return (
    <>{isLoading.value ? <Loading /> : <div></div>}
    </>
    )
}
[サーバー]                     [クライアント]
 ┌────────────────┐     ┌────────────────────┐
 │ PostPage.tsx   │     │ hydrated islands    │
 │ SSR で HTML 生成│───▶│ signal, useEffect等 │
 └────────────────┘     └────────────────────┘

サーバーで isLoading.value を評価しても「クライアントの状態」とは関係ない。

routes/[slug].tsx 内で isLoading.value を使っても表示されない

ためす

islands/PostLoading.tsx とかで変更されるものを渡してみる

import { useEffect, useState } from "preact/hooks";
import { Post } from "../types/post.ts";
import Loading from "../components/molecules/Loading.tsx";

export default function PostLoading({ post }: { post: Post }) {
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    setIsLoading(true);
    const timeout = setTimeout(() => {
      setIsLoading(false);
    }, 2000);
    return () => clearTimeout(timeout);
  }, [post]);

  return isLoading ? <Loading /> : null;
}

ローディングが表示される。 でもコレって実際にはpostの取得が完了したときにローディングを表示しているので謎に待ち時間だけ増えてない?

できたぽい

  • signalで isLoading フラグを用意する
  • islands/RouteEvents.tsx でページの遷移を検知する
  • islands/PostWrapper.tsx でisLoadingの値を検知してロード画面の表示/非表示を行う。
  • routes/[slug].tsx では PostWrapper.ts を呼び出す。
signals/loading.ts
import { signal } from "@preact/signals";

export const isLoading = signal(false);
islands/RouteEvents.ts
import { useEffect } from "preact/hooks";
import { isLoading } from "../signals/loading.ts";

export default function RouteEvents() {
  useEffect(() => {
    let previousPath = location.pathname + location.search;

    const handleClick = (e: MouseEvent) => {
      const anchor = (e.target as HTMLElement).closest("a");
      if (!anchor) return;

      const href = anchor.getAttribute("href");
      if (
        !href ||
        anchor.target === "_blank" ||
        href.startsWith("http") ||
        href.startsWith("#") ||
        (href === location.pathname && location.hash !== "")
      ) return;

      isLoading.value = true;
      previousPath = location.pathname + location.search;
    };

    const handlePopState = () => {
      const currentPath = location.pathname + location.search;

      // ハッシュだけが変わっている(パスは同じ)の場合は無視
      if (currentPath === previousPath) return;

      isLoading.value = true;
      previousPath = currentPath;
    };

    addEventListener("click", handleClick);
    addEventListener("popstate", handlePopState);

    return () => {
      removeEventListener("click", handleClick);
      removeEventListener("popstate", handlePopState);
    };
  }, []);

  return null;
}

<RouteEvents /> は一度だけ設定すれば良い。(再マウントされる必要がないのでkeyはわたさなくていい)

routes/_layout.tsx
import Footer from "../components/Footer.tsx";
import { FreshContext } from "$fresh/server.ts";
import { getCommonPageData } from "../utils/getCommonPageData.ts";
import Navigation from "../components/organisms/Navigation.tsx";
import Header from "../components/Header.tsx";
import { Partial } from "$fresh/runtime.ts";
import RouteEvents from "../islands/RouteEvents.tsx";

export default async function Layout(req: Request, ctx: FreshContext) {
  const common = await getCommonPageData();
  const postMap = common.postMap;

  return (
    <>
      <div class="flex flex-1 w-full">
        {/* 固定ヘッダー */}
        <Header />
        {/* サイドバー(PCのみ) */}
        <aside class="hidden lg:flex flex-col w-72 h-screen fixed pt-40 left-0 z-40">
          <Navigation postMap={postMap} />
        </aside>
        <div class="flex-1 lg:ml-80 flex flex-col min-h-screen justify-between min-w-0">
          <div class="flex-1 py-8 min-w-0">
            <RouteEvents />
            <Partial name="content">
              <ctx.Component />
            </Partial>
          </div>
          <div class="order-last">
            <Footer />
          </div>
        </div>
      </div>
    </>
  );
}
islands/PostWrapper.tsx
import { useEffect, useState } from "preact/hooks";
import { isLoading } from "../signals/loading.ts";
import Loading from "../components/molecules/Loading.tsx";
import CoverImage from "../islands/CoverImage.tsx";
import MarkdownContent from "../components/MarkdownContent.tsx";
import Overview from "../components/Overview.tsx";
import { Post } from "../types/post.ts";

export default function PostWrapper({ post }: { post: Post }) {
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    isLoading.value = true;
    requestAnimationFrame(() => {
      isLoading.value = false;
      setMounted(true);
    });
  }, [post.slug]);

  if (!mounted || isLoading.value) {
    return <Loading />;
  }

  return (
    <div class="flex flex-row min-w-0 justify-around mx-8">
      <main class="flex-1 min-w-0 max-w-3xl border-2 border-gray-600 rounded-xl">
        <div class="markdown-body box-border py-6 px-4 rounded-xl">
          <h1 class="py-8">{post.title}</h1>
          <div class="flex justify-end text-sm text-subText flex-col lg:flex-row">
            <div class="mx-2">
              公開日 <time>{/* date */}</time>
            </div>
            <div class="mx-2">
              更新日 <time>{/* date */}</time>
            </div>
          </div>
          <CoverImage imageName={post.coverImage ?? ""} />
          <MarkdownContent content={post.content} />
        </div>
      </main>
      <aside class="w-1/4 p-4 max-w-xs">
        <Overview post={post} />
      </aside>
    </div>
  );
}
routes/[slug].tsx
export default function PostPage({ data }: PageProps) {
  const { post } = data;
  return (
    <>
      <Head>
        <style dangerouslySetInnerHTML={{ __html: CSS }} />
        <link
          rel="stylesheet"
          href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css"
        />
      </Head>
      <PostWrapper post={post} />
      </>
  );
}

これはできてない。jsがないときにはローディング無しでコンテンツを表示してほしい。

さらに修正

サイトアイコン
公開日
更新日