feat: migrate from React to Preact, add Vitest testing, and optimize bundle

Major framework and tooling changes:
- Migrate from React 19 to Preact 10 with preact-iso for routing
- Replace @mantine/hooks with custom hooks (useBooleanToggle, useViewportSize)
- Switch from @heroicons/react to lucide-preact for icons
- Replace chance.js with random-js for RNG

Testing infrastructure:
- Add Vitest with @testing-library/preact and happy-dom
- Set up test configuration and initial App tests
- Add Vitest UI for interactive test running

Build optimizations:
- Add cssnano for CSS minification
- Configure aggressive bundle optimizations in Vite
- Update to Tailwind CSS 4.1.17

Project structure:
- Consolidate entry point from main.tsx to index.tsx
- Reorganize CSS location (styles/index.css → index.css)
- Add ESLint with preact config
- Update TypeScript configuration for Preact
This commit is contained in:
Ryan Walters
2025-11-06 21:56:53 -06:00
parent d668a21750
commit e632e69b91
18 changed files with 3057 additions and 532 deletions

2
.gitignore vendored
View File

@@ -7,6 +7,8 @@ yarn-error.log*
pnpm-debug.log* pnpm-debug.log*
lerna-debug.log* lerna-debug.log*
stats.html
*.js
node_modules node_modules
dist dist
dist-ssr dist-ssr

View File

@@ -19,10 +19,7 @@
property="article:published_time" property="article:published_time"
content="2022-11-25T08:54:58.977Z" content="2022-11-25T08:54:58.977Z"
/> />
<meta <meta property="og:image" content="https://grain.xevion.dev/bg.jpeg" />
property="og:image"
content="https://grain.xevion.dev/bg.jpeg"
/>
<meta <meta
property="og:image:secure_url" property="og:image:secure_url"
content="https://grain.xevion.dev/bg.jpeg" content="https://grain.xevion.dev/bg.jpeg"
@@ -34,10 +31,7 @@
content="A simple gradient image generated with Grain." content="A simple gradient image generated with Grain."
/> />
<meta property="og:image:type" content="jpeg" /> <meta property="og:image:type" content="jpeg" />
<meta <meta name="twitter:image" content="https://grain.xevion.dev/bg.jpeg" />
name="twitter:image"
content="https://grain.xevion.dev/bg.jpeg"
/>
<meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:url" content="https://grain.xevion.dev/" /> <meta name="twitter:url" content="https://grain.xevion.dev/" />
<meta name="twitter:domain" content="https://grain.xevion.dev/" /> <meta name="twitter:domain" content="https://grain.xevion.dev/" />
@@ -49,6 +43,6 @@
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script prerender type="module" src="/src/index.tsx"></script>
</body> </body>
</html> </html>

View File

@@ -1,33 +1,42 @@
{ {
"name": "noise",
"private": true, "private": true,
"version": "1.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"preinstall": "npx only-allow pnpm",
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"preview": "vite preview" "preview": "vite preview",
"test": "vitest",
"test:ui": "vitest --ui",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
}, },
"dependencies": { "dependencies": {
"@heroicons/react": "^2.2.0", "@tailwindcss/vite": "^4.1.17",
"@mantine/hooks": "^8.2.4",
"@tailwindcss/vite": "^4.1.11",
"@use-it/event-listener": "^0.1.7",
"cssnano": "^7.1.0", "cssnano": "^7.1.0",
"lucide-preact": "^0.468.0",
"preact": "^10.27.2",
"preact-iso": "^2.11.0",
"preact-render-to-string": "^6.6.3",
"random-js": "^2.1.0", "random-js": "^2.1.0",
"react": "^19.1.1", "tailwindcss": "^4.1.17"
"react-dom": "^19.1.1",
"tailwindcss": "^4.1.11"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.2.1", "@preact/preset-vite": "^2.10.2",
"@types/react": "^19.1.9", "@testing-library/jest-dom": "^6.9.1",
"@types/react-dom": "^19.1.7", "@testing-library/preact": "^3.2.4",
"@vitejs/plugin-react": "^5.0.0", "@types/node": "^24.10.0",
"rollup-plugin-visualizer": "^6.0.3", "@vitest/ui": "4.0.7",
"typescript": "^5.9.2", "eslint": "^9.39.1",
"vite": "^7.1.1", "eslint-config-preact": "^2.0.0",
"vite-tsconfig-paths": "^5.1.4" "happy-dom": "^20.0.10",
"rollup-plugin-visualizer": "^6.0.5",
"typescript": "^5.9.3",
"vite": "^7.2.1",
"vitest": "^4.0.7"
},
"eslintConfig": {
"extends": "preact"
}, },
"packageManager": "pnpm@9.15.1+sha512.1acb565e6193efbebda772702950469150cf12bcc764262e7587e71d19dc98a423dff9536e57ea44c49bdf790ff694e83c27be5faa23d67e0c033b583be4bfcf" "packageManager": "pnpm@9.15.1+sha512.1acb565e6193efbebda772702950469150cf12bcc764262e7587e71d19dc98a423dff9536e57ea44c49bdf790ff694e83c27be5faa23d67e0c033b583be4bfcf"
} }

3231
pnpm-lock.yaml generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,4 @@
import { SparklesIcon } from "@heroicons/react/20/solid"; import { Sparkles, ShieldAlert } from "lucide-preact";
import { ShieldExclamationIcon } from "@heroicons/react/24/solid";
const Post = () => { const Post = () => {
return ( return (
@@ -23,7 +22,7 @@ const Post = () => {
target="_blank" target="_blank"
className="hover:text-yellow-600 transition-colors cursor-pointer" className="hover:text-yellow-600 transition-colors cursor-pointer"
> >
<SparklesIcon className="h-4 inline mb-2.5 m-2 " /> <Sparkles className="h-4 inline mb-2.5 m-2 " />
</a> </a>
</span> </span>
</div> </div>
@@ -47,24 +46,24 @@ const Post = () => {
</p> </p>
<p> <p>
By using a SVG with a{" "} By using a SVG with a{" "}
<pre className="inline">&lt;feTurbulence&gt;</pre> filter inside, <code>&lt;feTurbulence&gt;</code> filter inside,
stacked upon several <pre className="inline">radial-gradient</pre>{" "} stacked upon several <code>radial-gradient</code>{" "}
background images, the same effect can be created. Since SVGs do not background images, the same effect can be created. Since SVGs do not
naturally repeat internally, the SVG itself must be generated in naturally repeat internally, the SVG itself must be generated in
such a way that the noise always displays the same way. such a way that the noise always displays the same way.
</p> </p>
<p> <p>
React comes in handy here, allowing composition of an SVG, and then React comes in handy here, allowing composition of an SVG, and then
conversion to a <pre className="inline">base64</pre> encoded string. conversion to a <code>base64</code> encoded string.
As a <pre className="inline">base64</pre> image, it can be fed into As a <code>base64</code> image, it can be fed into
the <pre className="inline">background</pre> CSS property, allowing the <code>background</code> CSS property, allowing
dynamic SVG generation. dynamic SVG generation.
</p> </p>
<div className="pt-3"> <div className="pt-3">
<a href="https://github.com/Xevion/grain"> <a href="https://github.com/Xevion/grain">
<div className="inline text-white text-medium drop-shadow-lg rounded border-2 shadow-xl border-zinc-600/75 m-2 p-2 bg-linear-to-r from-red-500 via-orange-500 to-orange-700"> <div className="inline text-white text-medium drop-shadow-lg rounded border-2 shadow-xl border-zinc-600/75 m-2 p-2 bg-linear-to-r from-red-500 via-orange-500 to-orange-700">
In Progress In Progress
<ShieldExclamationIcon className="inline h-[1.4rem] ml-3 drop-shadow-2xl" /> <ShieldAlert className="inline h-[1.4rem] ml-3 drop-shadow-2xl" />
</div> </div>
</a> </a>
</div> </div>

View File

View File

@@ -1,24 +1,23 @@
import { useViewportSize, useToggle } from "@mantine/hooks"; import { hydrate, prerender as ssr } from "preact-iso";
import useBackground from "@/utils/useBackground";
import Post from "@/components/Post";
import { import "./index.css";
ArrowPathIcon,
EyeIcon,
EyeSlashIcon,
} from "@heroicons/react/24/solid";
import { useMemo, useState } from "react";
function App() { import { useViewportSize } from "./utils/useViewportSize";
import { useBooleanToggle } from "./utils/useBooleanToggle";
import useBackground from "./utils/useBackground";
import Post from "./components/Post";
import { RefreshCw, Eye, EyeOff } from "lucide-preact";
import { useMemo } from "preact/hooks";
export function App() {
const { width, height } = useViewportSize(); const { width, height } = useViewportSize();
const { svg, backgrounds, regenerate } = useBackground({ const { svg, backgrounds, regenerate } = useBackground({
width, width,
height, height,
ratio: 0.4, ratio: 0.4,
}); });
const [postHidden, toggleHidden] = useToggle([false, true]); const [postHidden, toggleHidden] = useBooleanToggle(false);
const [iconSpinning, toggleIconSpinning] = useBooleanToggle(false);
const [iconSpinning, toggleIconSpinning] = useToggle([false, true]);
const style = useMemo(() => { const style = useMemo(() => {
return { return {
@@ -42,7 +41,7 @@ function App() {
setTimeout(() => toggleIconSpinning(false), 200); setTimeout(() => toggleIconSpinning(false), 200);
}} }}
> >
<ArrowPathIcon <RefreshCw
className={`transition-transform duration-200 ${ className={`transition-transform duration-200 ${
iconSpinning ? "rotate-180" : "rotate-0" iconSpinning ? "rotate-180" : "rotate-0"
}`} }`}
@@ -52,14 +51,13 @@ function App() {
className="block p-2 w-10 h-10 rounded mx-auto xs:mx-0 xs:ml-5 mt-5 shadow-inner-md bg-zinc-700 text-zinc-100 button" className="block p-2 w-10 h-10 rounded mx-auto xs:mx-0 xs:ml-5 mt-5 shadow-inner-md bg-zinc-700 text-zinc-100 button"
onClick={() => toggleHidden()} onClick={() => toggleHidden()}
> >
{postHidden ? <EyeIcon /> : <EyeSlashIcon />} {postHidden ? <Eye /> : <EyeOff />}
</button> </button>
</div> </div>
<div <div
className={`h-screen transition-opacity ease-in-out duration-75 ${ className={`h-screen transition-opacity ease-in-out duration-75 ${
postHidden ? "opacity-0 pointer-events-none" : "" postHidden ? "opacity-0 pointer-events-none" : ""
} flex col-span-9 sm:col-span-6 md:col-span-5 w-full min-h-screen`} } flex col-span-9 sm:col-span-6 md:col-span-5 w-full min-h-screen`}
> >
<div className="bg-white overflow-y-auto"> <div className="bg-white overflow-y-auto">
<Post /> <Post />
@@ -71,4 +69,10 @@ function App() {
); );
} }
export default App; if (typeof window !== "undefined") {
hydrate(<App />, document.getElementById("root"));
}
export async function prerender(data: any) {
return await ssr(<App {...data} />);
}

View File

@@ -1,10 +0,0 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "@/components/App";
import "@/styles/index.css";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

48
src/test/App.test.tsx Normal file
View File

@@ -0,0 +1,48 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { h } from "preact";
import { render } from "@testing-library/preact";
import type { FunctionComponent } from "preact";
describe("App", () => {
let App: FunctionComponent;
beforeEach(async () => {
// Mock window dimensions for useViewportSize hook
Object.defineProperty(window, "innerWidth", {
writable: true,
configurable: true,
value: 1920,
});
Object.defineProperty(window, "innerHeight", {
writable: true,
configurable: true,
value: 1080,
});
// Mock btoa for useBackground hook
global.btoa = vi.fn((str) => Buffer.from(str).toString("base64"));
// Dynamically import App after mocks are set up
const module = await import("../index");
App = module.App;
});
it("renders without crashing", () => {
expect(() => render(h(App, {}))).not.toThrow();
});
it("renders the Post component with main heading", () => {
const { getAllByText } = render(h(App, {}));
expect(getAllByText("Grain").length).toBeGreaterThan(0);
});
it("renders author information", () => {
const { getAllByText } = render(h(App, {}));
expect(getAllByText("Ryan Walters").length).toBeGreaterThan(0);
});
it("renders with gradient class", () => {
const { container } = render(h(App, {}));
expect(container.innerHTML).toContain("gradient");
});
});

2
src/test/setup.ts Normal file
View File

@@ -0,0 +1,2 @@
import "@testing-library/jest-dom/vitest";
import "preact/debug";

View File

@@ -1,8 +1,6 @@
import { Random } from "random-js"; import { Random } from "random-js";
import { useMemo, useState } from "react"; import { useMemo, useState } from "preact/hooks";
import ReactDOMServer from "react-dom/server"; import { getEdgePoint } from "./helpers";
import { getEdgePoint } from "@/utils/helpers";
interface useBackgroundProps { interface useBackgroundProps {
width: number; width: number;
height: number; height: number;
@@ -27,11 +25,7 @@ const generateBackground = (): string[] => {
.fill(null) .fill(null)
.map(() => random.pick(palette)) .map(() => random.pick(palette))
.map((color) => { .map((color) => {
const [x, y] = getEdgePoint( const [x, y] = getEdgePoint(random.integer(0, 400), 100, 100);
random.integer(0, 400),
100,
100
);
return `radial-gradient(farthest-corner at ${x}% ${y}%, ${color}, transparent 100%)`; return `radial-gradient(farthest-corner at ${x}% ${y}%, ${color}, transparent 100%)`;
}); });
}; };
@@ -47,35 +41,20 @@ const useBackground = ({
setBackground(generateBackground()); setBackground(generateBackground());
}; };
const noise = useMemo(() => { const noise = (): string => {
const svgWidth = Math.ceil((width ?? 1920) * ratio); const svgWidth = Math.ceil((width ?? 1920) * ratio);
const svgHeight = Math.ceil((height ?? 1080) * ratio); const svgHeight = Math.ceil((height ?? 1080) * ratio);
return ( const seed = random.integer(0, 1000000);
<svg
viewBox={`0 0 ${svgWidth} ${svgHeight}`} return `<svg viewBox="0 0 ${svgWidth} ${svgHeight}" xmlns="http://www.w3.org/2000/svg"><filter id="noiseFilter"><feTurbulence type="fractalNoise" baseFrequency="2.1" numOctaves="2" seed="${seed}" stitchTiles="stitch"/></filter><g opacity="0.9"><rect width="100%" height="100%" filter="url(#noiseFilter)"/></g></svg>`;
xmlns="http://www.w3.org/2000/svg" };
>
<filter id="noiseFilter">
<feTurbulence
type="fractalNoise"
baseFrequency="2.1"
numOctaves="2"
seed={random.integer(0, 1000000)}
stitchTiles="stitch"
/>
</filter>
<g opacity={0.9}>
<rect width="100%" height="100%" filter="url(#noiseFilter)" />
</g>
</svg>
);
}, [width, height, ratio]);
return { return {
regenerate, regenerate,
svg: `data:image/svg+xml;base64,${window.btoa( svg:
ReactDOMServer.renderToString(noise) typeof window !== "undefined"
)}`, ? `data:image/svg+xml;base64,${window.btoa(noise())}`
: "",
backgrounds: background, backgrounds: background,
}; };
}; };

View File

@@ -0,0 +1,18 @@
import { useCallback, useState } from "preact/hooks";
export function useBooleanToggle(
initialValue: boolean = false
): [boolean, (nextValue?: boolean) => void] {
const [value, setValue] = useState<boolean>(initialValue);
const toggle = useCallback((nextValue?: boolean) => {
if (typeof nextValue === "boolean") {
setValue(nextValue);
return;
}
setValue((prev) => !prev);
}, []);
return [value, toggle];
}

View File

@@ -0,0 +1,24 @@
import { useEffect, useState } from "preact/hooks";
export interface ViewportSize {
width: number;
height: number;
}
export function useViewportSize(): ViewportSize {
const [size, setSize] = useState<ViewportSize>({
width: typeof window !== "undefined" ? window.innerWidth : 0,
height: typeof window !== "undefined" ? window.innerHeight : 0,
});
useEffect(() => {
const handleResize = () => {
setSize({ width: window.innerWidth, height: window.innerHeight });
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
return size;
}

1
src/vite-env.d.ts vendored
View File

@@ -1 +0,0 @@
/// <reference types="vite/client" />

View File

@@ -1,25 +1,25 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ESNext", "target": "ES2020",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext", "module": "ESNext",
"moduleResolution": "Node", "moduleResolution": "bundler",
"resolveJsonModule": true, "lib": ["DOM", "DOM.Iterable", "ESNext"],
"isolatedModules": true,
"noEmit": true, "noEmit": true,
"allowJs": true,
"checkJs": true,
/* Preact Config */
"jsx": "react-jsx", "jsx": "react-jsx",
"baseUrl": "./src/", "jsxImportSource": "preact",
"skipLibCheck": true,
"baseUrl": "./",
"paths": { "paths": {
"@/*": ["./*"] "react": ["node_modules/preact/compat/"],
} "react-dom": ["node_modules/preact/compat/"],
"@/*": ["src/*"]
},
"outDir": "./dist"
}, },
"include": ["src"], "include": ["node_modules/vite/client.d.ts", "src"]
"references": [{ "path": "./tsconfig.node.json" }]
} }

View File

@@ -1,9 +0,0 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -1,6 +1,5 @@
import { defineConfig, loadEnv } from "vite"; import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react"; import preact from "@preact/preset-vite";
import tsconfigPaths from "vite-tsconfig-paths";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import { visualizer } from "rollup-plugin-visualizer"; import { visualizer } from "rollup-plugin-visualizer";
import cssnano from "cssnano"; import cssnano from "cssnano";
@@ -12,11 +11,13 @@ export default ({ mode }) => {
return defineConfig({ return defineConfig({
base: "/", base: "/",
plugins: [ plugins: [
react(), preact({
tsconfigPaths(), prerender: {
enabled: true,
renderTarget: "#root",
},
}),
tailwindcss(), tailwindcss(),
cssnano(),
visualizer({ visualizer({
template: "treemap", template: "treemap",
open: true, // Automatically open the report in your browser after build open: true, // Automatically open the report in your browser after build
@@ -25,17 +26,5 @@ export default ({ mode }) => {
brotliSize: true, // Show brotli size brotliSize: true, // Show brotli size
}), }),
], ],
build: {
rollupOptions: {
treeshake: {
// Remove unused module exports
moduleSideEffects: false,
// Optimize property access
propertyReadSideEffects: false,
// Remove unused imports
tryCatchDeoptimization: false,
},
},
},
}); });
}; };

28
vitest.config.ts Normal file
View File

@@ -0,0 +1,28 @@
import { defineConfig } from "vitest/config";
import preact from "@preact/preset-vite";
export default defineConfig({
plugins: [preact()],
test: {
environment: "happy-dom",
globals: true,
setupFiles: ["./src/test/setup.ts"],
coverage: {
reporter: ["text", "json", "html"],
exclude: [
"node_modules/",
"src/test/",
"**/*.config.ts",
"**/*.d.ts",
],
},
},
resolve: {
alias: {
"react": "preact/compat",
"react-dom": "preact/compat",
"react-dom/test-utils": "preact/test-utils",
},
dedupe: ["preact"],
},
});