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*
lerna-debug.log*
stats.html
*.js
node_modules
dist
dist-ssr

View File

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

View File

@@ -1,33 +1,42 @@
{
"name": "noise",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"preinstall": "npx only-allow pnpm",
"dev": "vite",
"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": {
"@heroicons/react": "^2.2.0",
"@mantine/hooks": "^8.2.4",
"@tailwindcss/vite": "^4.1.11",
"@use-it/event-listener": "^0.1.7",
"@tailwindcss/vite": "^4.1.17",
"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",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"tailwindcss": "^4.1.11"
"tailwindcss": "^4.1.17"
},
"devDependencies": {
"@types/node": "^24.2.1",
"@types/react": "^19.1.9",
"@types/react-dom": "^19.1.7",
"@vitejs/plugin-react": "^5.0.0",
"rollup-plugin-visualizer": "^6.0.3",
"typescript": "^5.9.2",
"vite": "^7.1.1",
"vite-tsconfig-paths": "^5.1.4"
"@preact/preset-vite": "^2.10.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/preact": "^3.2.4",
"@types/node": "^24.10.0",
"@vitest/ui": "4.0.7",
"eslint": "^9.39.1",
"eslint-config-preact": "^2.0.0",
"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"
}

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 { ShieldExclamationIcon } from "@heroicons/react/24/solid";
import { Sparkles, ShieldAlert } from "lucide-preact";
const Post = () => {
return (
@@ -23,7 +22,7 @@ const Post = () => {
target="_blank"
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>
</span>
</div>
@@ -47,24 +46,24 @@ const Post = () => {
</p>
<p>
By using a SVG with a{" "}
<pre className="inline">&lt;feTurbulence&gt;</pre> filter inside,
stacked upon several <pre className="inline">radial-gradient</pre>{" "}
<code>&lt;feTurbulence&gt;</code> filter inside,
stacked upon several <code>radial-gradient</code>{" "}
background images, the same effect can be created. Since SVGs do not
naturally repeat internally, the SVG itself must be generated in
such a way that the noise always displays the same way.
</p>
<p>
React comes in handy here, allowing composition of an SVG, and then
conversion to a <pre className="inline">base64</pre> encoded string.
As a <pre className="inline">base64</pre> image, it can be fed into
the <pre className="inline">background</pre> CSS property, allowing
conversion to a <code>base64</code> encoded string.
As a <code>base64</code> image, it can be fed into
the <code>background</code> CSS property, allowing
dynamic SVG generation.
</p>
<div className="pt-3">
<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">
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>
</a>
</div>

View File

View File

@@ -1,24 +1,23 @@
import { useViewportSize, useToggle } from "@mantine/hooks";
import useBackground from "@/utils/useBackground";
import Post from "@/components/Post";
import { hydrate, prerender as ssr } from "preact-iso";
import {
ArrowPathIcon,
EyeIcon,
EyeSlashIcon,
} from "@heroicons/react/24/solid";
import { useMemo, useState } from "react";
import "./index.css";
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 { svg, backgrounds, regenerate } = useBackground({
width,
height,
ratio: 0.4,
});
const [postHidden, toggleHidden] = useToggle([false, true]);
const [iconSpinning, toggleIconSpinning] = useToggle([false, true]);
const [postHidden, toggleHidden] = useBooleanToggle(false);
const [iconSpinning, toggleIconSpinning] = useBooleanToggle(false);
const style = useMemo(() => {
return {
@@ -42,7 +41,7 @@ function App() {
setTimeout(() => toggleIconSpinning(false), 200);
}}
>
<ArrowPathIcon
<RefreshCw
className={`transition-transform duration-200 ${
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"
onClick={() => toggleHidden()}
>
{postHidden ? <EyeIcon /> : <EyeSlashIcon />}
{postHidden ? <Eye /> : <EyeOff />}
</button>
</div>
<div
className={`h-screen transition-opacity ease-in-out duration-75 ${
postHidden ? "opacity-0 pointer-events-none" : ""
} flex col-span-9 sm:col-span-6 md:col-span-5 w-full min-h-screen`}
>
<div className="bg-white overflow-y-auto">
<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 { useMemo, useState } from "react";
import ReactDOMServer from "react-dom/server";
import { getEdgePoint } from "@/utils/helpers";
import { useMemo, useState } from "preact/hooks";
import { getEdgePoint } from "./helpers";
interface useBackgroundProps {
width: number;
height: number;
@@ -27,11 +25,7 @@ const generateBackground = (): string[] => {
.fill(null)
.map(() => random.pick(palette))
.map((color) => {
const [x, y] = getEdgePoint(
random.integer(0, 400),
100,
100
);
const [x, y] = getEdgePoint(random.integer(0, 400), 100, 100);
return `radial-gradient(farthest-corner at ${x}% ${y}%, ${color}, transparent 100%)`;
});
};
@@ -47,35 +41,20 @@ const useBackground = ({
setBackground(generateBackground());
};
const noise = useMemo(() => {
const noise = (): string => {
const svgWidth = Math.ceil((width ?? 1920) * ratio);
const svgHeight = Math.ceil((height ?? 1080) * ratio);
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={random.integer(0, 1000000)}
stitchTiles="stitch"
/>
</filter>
<g opacity={0.9}>
<rect width="100%" height="100%" filter="url(#noiseFilter)" />
</g>
</svg>
);
}, [width, height, ratio]);
const seed = random.integer(0, 1000000);
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>`;
};
return {
regenerate,
svg: `data:image/svg+xml;base64,${window.btoa(
ReactDOMServer.renderToString(noise)
)}`,
svg:
typeof window !== "undefined"
? `data:image/svg+xml;base64,${window.btoa(noise())}`
: "",
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": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"moduleResolution": "bundler",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"noEmit": true,
"allowJs": true,
"checkJs": true,
/* Preact Config */
"jsx": "react-jsx",
"baseUrl": "./src/",
"jsxImportSource": "preact",
"skipLibCheck": true,
"baseUrl": "./",
"paths": {
"@/*": ["./*"]
}
"react": ["node_modules/preact/compat/"],
"react-dom": ["node_modules/preact/compat/"],
"@/*": ["src/*"]
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
"outDir": "./dist"
},
"include": ["node_modules/vite/client.d.ts", "src"]
}

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 react from "@vitejs/plugin-react";
import tsconfigPaths from "vite-tsconfig-paths";
import preact from "@preact/preset-vite";
import tailwindcss from "@tailwindcss/vite";
import { visualizer } from "rollup-plugin-visualizer";
import cssnano from "cssnano";
@@ -12,11 +11,13 @@ export default ({ mode }) => {
return defineConfig({
base: "/",
plugins: [
react(),
tsconfigPaths(),
preact({
prerender: {
enabled: true,
renderTarget: "#root",
},
}),
tailwindcss(),
cssnano(),
visualizer({
template: "treemap",
open: true, // Automatically open the report in your browser after build
@@ -25,17 +26,5 @@ export default ({ mode }) => {
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"],
},
});