Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
721195b
#001 ビルド設定の本番化 + ポリフィル除去
N0BU0x Mar 20, 2026
f744b77
#005-008 パフォーマンス罠修正
N0BU0x Mar 20, 2026
dce842a
#002-003 gzip圧縮 + WASM/FFmpeg外部ファイル化
N0BU0x Mar 20, 2026
6e8f76e
#004 重量ライブラリ除去 + コード分割
N0BU0x Mar 20, 2026
8d0844b
#004a SearchContainerのlazy化を戻す + fetchBinaryをfetch APIに修正
N0BU0x Mar 20, 2026
db2e9a9
#009-015 大量改善: jQuery除去/DM罠修正/フォント/キャッシュ/defer
N0BU0x Mar 20, 2026
09babde
#016-017 Tailwind CDN除去 + OTFフォントWOFF2化
N0BU0x Mar 21, 2026
3e6c061
#018-023 CoveredImage簡素化/API最適化/フォントサブセット/DBインデックス
N0BU0x Mar 21, 2026
dda80dd
#024-028 コード分割強化/Invoker API polyfill/初回描画高速化/DM最適化
N0BU0x Mar 21, 2026
ff5b966
#029-031 ローディングスケルトン/ミドルウェア最適化/Post画像クエリ分離
N0BU0x Mar 21, 2026
46fbc8e
#032-037 画像最適化/メディア遅延読込/KaTeXフォント削減
N0BU0x Mar 21, 2026
1babdea
#038-039 DOMContentLoaded除去/フォントpreload追加
N0BU0x Mar 21, 2026
bc1ff43
#040-042 プロフィール画像lazy/DateTimeFormatキャッシュ
N0BU0x Mar 21, 2026
b9c27c4
#043-045 APIプリフェッチ/翻訳動的import/フェッチャー最適化
N0BU0x Mar 21, 2026
b6f29a4
#046-048 Babel target chrome>=120/PausableMovie native img/GIF→WebP変換
N0BU0x Mar 21, 2026
a8996a4
#049-050 CLS改善(画像width/height追加)/LCP改善(priority画像のeager loading)
N0BU0x Mar 21, 2026
9f93406
#051 Font Awesome SVGスプライト最小化(solid 654KB→7KB, regular 110KB→1KB)
N0BU0x Mar 21, 2026
8ff37f5
#052 APIレスポンスキャッシュ追加(post/comments)
N0BU0x Mar 21, 2026
da74d25
#053-054 content-visibility:auto追加/未使用フォントファイル削除(20MB削減)
N0BU0x Mar 21, 2026
47f6025
#055-056 favicon追加(404防止)/gzip level9
N0BU0x Mar 21, 2026
0cfd757
#057 APIプリフェッチ拡張(/api/v1/me, DM endpoints)
N0BU0x Mar 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions application/client/babel.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,15 @@ module.exports = {
[
"@babel/preset-env",
{
targets: "ie 11",
corejs: "3",
modules: "commonjs",
targets: "chrome >= 120",
modules: false,
useBuiltIns: false,
},
],
[
"@babel/preset-react",
{
development: true,
development: false,
runtime: "automatic",
},
],
Expand Down
4 changes: 3 additions & 1 deletion application/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"license": "MPL-2.0",
"author": "CyberAgent, Inc.",
"scripts": {
"build": "NODE_ENV=development webpack",
"build": "NODE_ENV=production webpack",
"typecheck": "tsc"
},
"dependencies": {
Expand Down Expand Up @@ -57,6 +57,7 @@
"@babel/preset-env": "7.28.3",
"@babel/preset-react": "7.27.1",
"@babel/preset-typescript": "7.27.1",
"@tailwindcss/postcss": "4.2.2",
"@tsconfig/strictest": "2.0.8",
"@types/bluebird": "3.5.42",
"@types/common-tags": "1.8.4",
Expand All @@ -83,6 +84,7 @@
"postcss-loader": "8.2.0",
"postcss-preset-env": "10.4.0",
"react-markdown": "10.1.0",
"tailwindcss": "4.2.2",
"typescript": "5.9.3",
"webpack": "5.102.1",
"webpack-cli": "6.0.1",
Expand Down
8 changes: 2 additions & 6 deletions application/client/postcss.config.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
const postcssImport = require("postcss-import");
const postcssPresetEnv = require("postcss-preset-env");
const tailwindcss = require("@tailwindcss/postcss");

module.exports = {
plugins: [
postcssImport(),
postcssPresetEnv({
stage: 3,
}),
tailwindcss(),
],
};
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import moment from "moment";
import { useCallback, useEffect, useState } from "react";

import { Button } from "@web-speed-hackathon-2026/client/src/components/foundation/Button";
Expand All @@ -8,6 +7,8 @@ import { useWs } from "@web-speed-hackathon-2026/client/src/hooks/use_ws";
import { fetchJSON } from "@web-speed-hackathon-2026/client/src/utils/fetchers";
import { getProfileImagePath } from "@web-speed-hackathon-2026/client/src/utils/get_path";

const rtf = new Intl.RelativeTimeFormat("ja", { numeric: "auto" });

interface Props {
activeUser: Models.User;
newDmModalId: string;
Expand Down Expand Up @@ -86,8 +87,10 @@ export const DirectMessageListPage = ({ activeUser, newDmModalId }: Props) => {
<div className="border-cax-border flex gap-4 border-b px-4 pt-2 pb-4">
<img
alt={peer.profileImage.alt}
className="w-12 shrink-0 self-start rounded-full"
className="w-12 shrink-0 self-start rounded-full object-cover"
src={getProfileImagePath(peer.profileImage.id)}
width={48}
height={48}
/>
<div className="flex flex-1 flex-col">
<div className="flex items-center justify-between">
Expand All @@ -100,7 +103,17 @@ export const DirectMessageListPage = ({ activeUser, newDmModalId }: Props) => {
className="text-cax-text-subtle text-xs"
dateTime={lastMessage.createdAt}
>
{moment(lastMessage.createdAt).locale("ja").fromNow()}
{(() => {
const diff = Date.now() - new Date(lastMessage.createdAt).getTime();
const sec = Math.round(diff / 1000);
if (sec < 60) return rtf.format(-sec, "second");
const min = Math.round(diff / 60000);
if (min < 60) return rtf.format(-min, "minute");
const hr = Math.round(diff / 3600000);
if (hr < 24) return rtf.format(-hr, "hour");
const day = Math.round(diff / 86400000);
return rtf.format(-day, "day");
})()}
</time>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import classNames from "classnames";
import moment from "moment";
import {
ChangeEvent,
useCallback,
Expand All @@ -15,6 +14,8 @@ import { FontAwesomeIcon } from "@web-speed-hackathon-2026/client/src/components
import { DirectMessageFormData } from "@web-speed-hackathon-2026/client/src/direct_message/types";
import { getProfileImagePath } from "@web-speed-hackathon-2026/client/src/utils/get_path";

const timeFormatter = new Intl.DateTimeFormat("ja", { hour: "2-digit", minute: "2-digit", hour12: false });

interface Props {
conversationError: Error | null;
conversation: Models.DirectMessageConversation;
Expand Down Expand Up @@ -74,15 +75,17 @@ export const DirectMessagePage = ({
);

useEffect(() => {
const id = setInterval(() => {
const height = Number(window.getComputedStyle(document.body).height.replace("px", ""));
const scrollToBottom = () => {
const height = document.body.scrollHeight;
if (height !== scrollHeightRef.current) {
scrollHeightRef.current = height;
window.scrollTo(0, height);
}
}, 1);

return () => clearInterval(id);
};
scrollToBottom();
const observer = new MutationObserver(scrollToBottom);
observer.observe(document.body, { childList: true, subtree: true });
return () => observer.disconnect();
}, []);

if (conversationError != null) {
Expand All @@ -100,6 +103,8 @@ export const DirectMessagePage = ({
alt={peer.profileImage.alt}
className="h-12 w-12 rounded-full object-cover"
src={getProfileImagePath(peer.profileImage.id)}
width={48}
height={48}
/>
<div className="min-w-0">
<h1 className="overflow-hidden text-xl font-bold text-ellipsis whitespace-nowrap">
Expand Down Expand Up @@ -141,7 +146,7 @@ export const DirectMessagePage = ({
</p>
<div className="flex gap-1 text-xs">
<time dateTime={message.createdAt}>
{moment(message.createdAt).locale("ja").format("HH:mm")}
{timeFormatter.format(new Date(message.createdAt))}
</time>
{isActiveUserSend && message.isRead && (
<span className="text-cax-text-muted">既読</span>
Expand Down
25 changes: 3 additions & 22 deletions application/client/src/components/foundation/AspectRatioBox.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ReactNode, useEffect, useRef, useState } from "react";
import { ReactNode } from "react";

interface Props {
aspectHeight: number;
Expand All @@ -10,28 +10,9 @@ interface Props {
* 親要素の横幅を基準にして、指定したアスペクト比のブロック要素を作ります
*/
export const AspectRatioBox = ({ aspectHeight, aspectWidth, children }: Props) => {
const ref = useRef<HTMLDivElement>(null);
const [clientHeight, setClientHeight] = useState(0);

useEffect(() => {
// clientWidth とアスペクト比から clientHeight を計算する
function calcStyle() {
const clientWidth = ref.current?.clientWidth ?? 0;
setClientHeight((clientWidth / aspectWidth) * aspectHeight);
}
setTimeout(() => calcStyle(), 500);

// ウィンドウサイズが変わるたびに計算する
window.addEventListener("resize", calcStyle, { passive: false });
return () => {
window.removeEventListener("resize", calcStyle);
};
}, [aspectHeight, aspectWidth]);

return (
<div ref={ref} className="relative h-1 w-full" style={{ height: clientHeight }}>
{/* 高さが計算できるまで render しない */}
{clientHeight !== 0 ? <div className="absolute inset-0">{children}</div> : null}
<div className="relative w-full" style={{ aspectRatio: `${aspectWidth} / ${aspectHeight}` }}>
<div className="absolute inset-0">{children}</div>
</div>
);
};
79 changes: 35 additions & 44 deletions application/client/src/components/foundation/CoveredImage.tsx
Original file line number Diff line number Diff line change
@@ -1,77 +1,68 @@
import classNames from "classnames";
import sizeOf from "image-size";
import { load, ImageIFD } from "piexifjs";
import { MouseEvent, RefCallback, useCallback, useId, useMemo, useState } from "react";
import { MouseEvent, useCallback, useId, useState } from "react";

import { Button } from "@web-speed-hackathon-2026/client/src/components/foundation/Button";
import { Modal } from "@web-speed-hackathon-2026/client/src/components/modal/Modal";
import { useFetch } from "@web-speed-hackathon-2026/client/src/hooks/use_fetch";
import { fetchBinary } from "@web-speed-hackathon-2026/client/src/utils/fetchers";

interface Props {
src: string;
priority?: boolean;
}

/**
* アスペクト比を維持したまま、要素のコンテンツボックス全体を埋めるように画像を拡大縮小します
*/
export const CoveredImage = ({ src }: Props) => {
export const CoveredImage = ({ src, priority }: Props) => {
const dialogId = useId();
// ダイアログの背景をクリックしたときに投稿詳細ページに遷移しないようにする
const handleDialogClick = useCallback((ev: MouseEvent<HTMLDialogElement>) => {
ev.stopPropagation();
}, []);

const { data, isLoading } = useFetch(src, fetchBinary);
const [alt, setAlt] = useState("");
const [altLoaded, setAltLoaded] = useState(false);

const imageSize = useMemo(() => {
return data != null ? sizeOf(Buffer.from(data)) : { height: 0, width: 0 };
}, [data]);

const alt = useMemo(() => {
const exif = data != null ? load(Buffer.from(data).toString("binary")) : null;
const raw = exif?.["0th"]?.[ImageIFD.ImageDescription];
return raw != null ? new TextDecoder().decode(Buffer.from(raw, "binary")) : "";
}, [data]);

const blobUrl = useMemo(() => {
return data != null ? URL.createObjectURL(new Blob([data])) : null;
}, [data]);

const [containerSize, setContainerSize] = useState({ height: 0, width: 0 });
const callbackRef = useCallback<RefCallback<HTMLDivElement>>((el) => {
setContainerSize({
height: el?.clientHeight ?? 0,
width: el?.clientWidth ?? 0,
});
}, []);

if (isLoading || data === null || blobUrl === null) {
return null;
}

const containerRatio = containerSize.height / containerSize.width;
const imageRatio = imageSize?.height / imageSize?.width;
// ALT テキストを EXIF からオンデマンドで取得(ボタンクリック時のみ)
const loadAlt = useCallback(async () => {
if (altLoaded) return;
setAltLoaded(true);
try {
const data = await fetchBinary(src);
const { load, ImageIFD } = await import("piexifjs");
const arr = new Uint8Array(data);
let binary = "";
for (let i = 0; i < arr.length; i++) {
binary += String.fromCharCode(arr[i]!);
}
const exif = load(binary);
const raw = exif?.["0th"]?.[ImageIFD.ImageDescription];
if (raw != null) {
const decoded = new TextDecoder().decode(
Uint8Array.from(raw as string, (c: string) => c.charCodeAt(0)),
);
setAlt(decoded);
}
} catch {
// EXIF 読み取り失敗は無視
}
}, [src, altLoaded]);

return (
<div ref={callbackRef} className="relative h-full w-full overflow-hidden">
<div className="relative h-full w-full overflow-hidden">
<img
alt={alt}
className={classNames(
"absolute left-1/2 top-1/2 max-w-none -translate-x-1/2 -translate-y-1/2",
{
"w-auto h-full": containerRatio > imageRatio,
"w-full h-auto": containerRatio <= imageRatio,
},
)}
src={blobUrl}
className="h-full w-full object-cover"
src={src}
loading={priority ? undefined : "lazy"}
{...(priority ? { fetchPriority: "high" as const } : {})}
/>

<button
className="border-cax-border bg-cax-surface-raised/90 text-cax-text-muted hover:bg-cax-surface absolute right-1 bottom-1 rounded-full border px-2 py-1 text-center text-xs"
type="button"
command="show-modal"
commandfor={dialogId}
onClick={loadAlt}
>
ALT を表示する
</button>
Expand Down
18 changes: 4 additions & 14 deletions application/client/src/components/foundation/InfiniteScroll.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,9 @@ export const InfiniteScroll = ({ children, fetchMore, items }: Props) => {

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

// 画面最下部にスクロールしたタイミングで、登録したハンドラを呼び出す
if (hasReached && !prevReachedRef.current) {
// アイテムがないときは追加で読み込まない
if (latestItem !== undefined) {
fetchMore();
}
Expand All @@ -29,19 +24,14 @@ export const InfiniteScroll = ({ children, fetchMore, items }: Props) => {
prevReachedRef.current = hasReached;
};

// 最初は実行されないので手動で呼び出す
prevReachedRef.current = false;
handler();

document.addEventListener("wheel", handler, { passive: false });
document.addEventListener("touchmove", handler, { passive: false });
document.addEventListener("resize", handler, { passive: false });
document.addEventListener("scroll", handler, { passive: false });
document.addEventListener("scroll", handler, { passive: true });
window.addEventListener("resize", handler, { passive: true });
return () => {
document.removeEventListener("wheel", handler);
document.removeEventListener("touchmove", handler);
document.removeEventListener("resize", handler);
document.removeEventListener("scroll", handler);
window.removeEventListener("resize", handler);
};
}, [latestItem, fetchMore]);

Expand Down
Loading
Loading