Skip to content

Commit 33f0fd7

Browse files
committed
2
1 parent 5a1d972 commit 33f0fd7

File tree

8 files changed

+110
-33
lines changed

8 files changed

+110
-33
lines changed

application/client/src/components/foundation/CoveredImage.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@ import { Modal } from "@web-speed-hackathon-2026/client/src/components/modal/Mod
77
interface Props {
88
src: string;
99
alt: string;
10+
/** 複数枚あるときは 1 枚だけ true(LCP・帯域の競合を避ける) */
11+
isLcpCandidate?: boolean;
1012
}
1113

1214
/**
1315
* LCP を安定させるため、バイナリ取得/解析は行わず直に表示します
1416
*/
15-
export const CoveredImage = ({ src, alt }: Props) => {
17+
export const CoveredImage = ({ src, alt, isLcpCandidate = true }: Props) => {
1618
const dialogId = useId();
1719
// ダイアログの背景をクリックしたときに投稿詳細ページに遷移しないようにする
1820
const handleDialogClick = useCallback((ev: MouseEvent<HTMLDialogElement>) => {
@@ -25,9 +27,9 @@ export const CoveredImage = ({ src, alt }: Props) => {
2527
alt={alt}
2628
className={classNames("absolute inset-0 h-full w-full object-cover")}
2729
src={src}
28-
loading="eager"
2930
decoding="async"
30-
fetchPriority="high"
31+
fetchPriority={isLcpCandidate ? "high" : "low"}
32+
loading={isLcpCandidate ? "eager" : "lazy"}
3133
/>
3234

3335
<button

application/client/src/components/foundation/InfiniteScroll.tsx

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,8 @@ export const InfiniteScroll = ({ children, fetchMore, items }: Props) => {
1313

1414
useEffect(() => {
1515
const handler = () => {
16-
// 念の為 2の18乗 回、最下部かどうかを確認する
17-
const hasReached = Array.from(Array(2 ** 18), () => {
18-
return window.innerHeight + Math.ceil(window.scrollY) >= document.body.offsetHeight;
19-
}).every(Boolean);
16+
const hasReached =
17+
window.innerHeight + Math.ceil(window.scrollY) >= document.body.offsetHeight;
2018

2119
// 画面最下部にスクロールしたタイミングで、登録したハンドラを呼び出す
2220
if (hasReached && !prevReachedRef.current) {
@@ -33,10 +31,10 @@ export const InfiniteScroll = ({ children, fetchMore, items }: Props) => {
3331
prevReachedRef.current = false;
3432
handler();
3533

36-
document.addEventListener("wheel", handler, { passive: false });
37-
document.addEventListener("touchmove", handler, { passive: false });
38-
document.addEventListener("resize", handler, { passive: false });
39-
document.addEventListener("scroll", handler, { passive: false });
34+
document.addEventListener("wheel", handler, { passive: true });
35+
document.addEventListener("touchmove", handler, { passive: true });
36+
document.addEventListener("resize", handler, { passive: true });
37+
document.addEventListener("scroll", handler, { passive: true });
4038
return () => {
4139
document.removeEventListener("wheel", handler);
4240
document.removeEventListener("touchmove", handler);

application/client/src/components/post/ImageArea.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,11 @@ export const ImageArea = ({ images }: Props) => {
2424
"row-span-2": images.length <= 2 || (images.length === 3 && idx === 0),
2525
})}
2626
>
27-
<CoveredImage src={getImagePath(image.id)} alt={image.alt} />
27+
<CoveredImage
28+
alt={image.alt}
29+
isLcpCandidate={idx === 0}
30+
src={getImagePath(image.id)}
31+
/>
2832
</div>
2933
);
3034
})}

application/client/src/components/post/PostItem.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ export const PostItem = ({ post }: Props) => {
2323
>
2424
<img
2525
alt={post.user.profileImage.alt}
26+
decoding="async"
27+
fetchPriority="low"
2628
src={getProfileImagePath(post.user.profileImage.id)}
2729
/>
2830
</Link>

application/client/src/containers/AppContainer.tsx

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Route, Routes, useLocation, useNavigate } from "react-router";
55
import { AppPage } from "@web-speed-hackathon-2026/client/src/components/application/AppPage";
66
import { AuthModalContainer } from "@web-speed-hackathon-2026/client/src/containers/AuthModalContainer";
77
import { NewPostModalContainer } from "@web-speed-hackathon-2026/client/src/containers/NewPostModalContainer";
8+
import { PostContainer } from "@web-speed-hackathon-2026/client/src/containers/PostContainer";
89
import { TimelineContainer } from "@web-speed-hackathon-2026/client/src/containers/TimelineContainer";
910
import { fetchJSON, sendJSON } from "@web-speed-hackathon-2026/client/src/utils/fetchers";
1011

@@ -36,12 +37,6 @@ const DirectMessageListContainer = lazy(() =>
3637
})),
3738
);
3839

39-
const PostContainer = lazy(() =>
40-
import("@web-speed-hackathon-2026/client/src/containers/PostContainer").then((m) => ({
41-
default: m.PostContainer,
42-
})),
43-
);
44-
4540
const SearchContainer = lazy(() =>
4641
import("@web-speed-hackathon-2026/client/src/containers/SearchContainer").then((m) => ({
4742
default: m.SearchContainer,
@@ -55,7 +50,6 @@ const TermContainer = lazy(() =>
5550
);
5651

5752
// ルート初期表示でだけ lazy chunk の取得を前倒しする
58-
// (render delay の短縮: Lighthouse が LCP 候補を決める前に chunk を揃える)
5953
if (typeof window !== "undefined") {
6054
const path = window.location.pathname;
6155

@@ -67,8 +61,6 @@ if (typeof window !== "undefined") {
6761
void import("@web-speed-hackathon-2026/client/src/containers/DirectMessageListContainer");
6862
} else if (path.startsWith("/dm/")) {
6963
void import("@web-speed-hackathon-2026/client/src/containers/DirectMessageContainer");
70-
} else if (/^\/posts\/[^/]+$/.test(path)) {
71-
void import("@web-speed-hackathon-2026/client/src/containers/PostContainer");
7264
}
7365
}
7466

application/client/src/containers/PostContainer.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ const PostContainerContent = ({ postId }: { postId: string | undefined }) => {
1515
);
1616

1717
const { data: comments, fetchMore } = useInfiniteFetch<Models.Comment>(
18-
`/api/v1/posts/${postId}/comments`,
18+
postId != null ? `/api/v1/posts/${postId}/comments` : "",
1919
fetchJSON,
2020
);
2121

application/client/src/index.tsx

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,10 @@ import { BrowserRouter } from "react-router";
55
import { AppContainer } from "@web-speed-hackathon-2026/client/src/containers/AppContainer";
66
import { store } from "@web-speed-hackathon-2026/client/src/store";
77

8-
window.addEventListener("load", () => {
9-
createRoot(document.getElementById("app")!).render(
10-
<Provider store={store}>
11-
<BrowserRouter>
12-
<AppContainer />
13-
</BrowserRouter>
14-
</Provider>,
15-
);
16-
});
8+
createRoot(document.getElementById("app")!).render(
9+
<Provider store={store}>
10+
<BrowserRouter>
11+
<AppContainer />
12+
</BrowserRouter>
13+
</Provider>,
14+
);

application/server/src/routes/static.ts

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,80 @@ import { parseSearchQuery } from "@web-speed-hackathon-2026/server/src/utils/par
1515

1616
export const staticRouter = Router();
1717

18+
/** HTML 埋め込み用: 説明文など巨大フィールドを除き、インラインスクリプトのパース時間を抑える */
19+
function trimUserForClientPreload(u: unknown): Record<string, unknown> | null {
20+
if (u == null || typeof u !== "object") return null;
21+
const raw = u as Record<string, unknown>;
22+
const pi = raw["profileImage"];
23+
const profileImage =
24+
pi != null && typeof pi === "object"
25+
? {
26+
alt: (pi as Record<string, unknown>)["alt"] ?? "",
27+
id: (pi as Record<string, unknown>)["id"],
28+
}
29+
: pi;
30+
return {
31+
id: raw["id"],
32+
name: raw["name"],
33+
profileImage,
34+
username: raw["username"],
35+
};
36+
}
37+
38+
function trimPostForClientPreload(raw: Record<string, unknown>): Record<string, unknown> {
39+
const images = raw["images"];
40+
const trimmedImages = Array.isArray(images)
41+
? images.map((im) => {
42+
const row = im as Record<string, unknown>;
43+
return { alt: row["alt"] ?? "", id: row["id"] };
44+
})
45+
: images;
46+
47+
const movie = raw["movie"];
48+
const trimmedMovie =
49+
movie != null && typeof movie === "object"
50+
? { id: (movie as Record<string, unknown>)["id"] }
51+
: movie;
52+
53+
const sound = raw["sound"];
54+
const trimmedSound =
55+
sound != null && typeof sound === "object"
56+
? { id: (sound as Record<string, unknown>)["id"] }
57+
: sound;
58+
59+
return {
60+
createdAt: raw["createdAt"],
61+
id: raw["id"],
62+
images: trimmedImages,
63+
movie: trimmedMovie,
64+
sound: trimmedSound,
65+
text: raw["text"],
66+
user: trimUserForClientPreload(raw["user"]),
67+
};
68+
}
69+
70+
/** 投稿詳細の LCP 候補(先頭画像 or 動画 GIF)を先読みして、JS 実行前から取得を開始する */
71+
function buildPostDetailLcpPreloadTags(post: Record<string, unknown> | null): string {
72+
if (post == null) return "";
73+
const parts: string[] = [];
74+
const images = post["images"];
75+
if (Array.isArray(images) && images.length > 0) {
76+
const first = images[0] as { id?: string } | undefined;
77+
if (first?.id != null && first.id !== "") {
78+
parts.push(
79+
`<link rel="preload" as="image" href="/images/${first.id}.jpg" fetchpriority="high">`,
80+
);
81+
}
82+
}
83+
const movie = post["movie"] as { id?: string } | null | undefined;
84+
if (movie != null && typeof movie === "object" && movie.id != null && movie.id !== "") {
85+
parts.push(
86+
`<link rel="preload" as="image" href="/movies/${movie.id}.gif" fetchpriority="high">`,
87+
);
88+
}
89+
return parts.join("");
90+
}
91+
1892
// index.html テンプレートをキャッシュ
1993
let _htmlTemplate: string | null = null;
2094
function getHtmlTemplate(): string {
@@ -139,7 +213,8 @@ async function buildPreloadData(req: Parameters<Parameters<typeof Router>[0]>[0]
139213
// 投稿詳細ページ
140214
const postId = postMatch[1];
141215
const post = await Post.findByPk(postId);
142-
data[`/api/v1/posts/${postId}`] = post ? post.toJSON() : null;
216+
data[`/api/v1/posts/${postId}`] =
217+
post != null ? trimPostForClientPreload(post.toJSON() as Record<string, unknown>) : null;
143218
}
144219
}
145220

@@ -255,8 +330,14 @@ staticRouter.use(async (req, res, next) => {
255330

256331
try {
257332
const preloadData = await buildPreloadData(req);
333+
const postMatch = req.path.match(/^\/posts\/([^/]+)$/);
334+
const postKey = postMatch != null ? `/api/v1/posts/${postMatch[1]}` : null;
335+
const postPayload =
336+
postKey != null && postKey in preloadData ? (preloadData[postKey] as Record<string, unknown> | null) : null;
337+
const lcpPreload = postKey != null ? buildPostDetailLcpPreloadTags(postPayload) : "";
258338
const script = `<script>window.__PRELOAD_DATA__=${JSON.stringify(preloadData)};</script>`;
259-
const html = template.replace("</head>", `${script}</head>`);
339+
let html = lcpPreload !== "" ? template.replace("<head>", `<head>${lcpPreload}`) : template;
340+
html = html.replace("</head>", `${script}</head>`);
260341
return res.type("text/html").send(html);
261342
} catch {
262343
return next();

0 commit comments

Comments
 (0)