create-react-adam 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -62,7 +62,10 @@ Every generated project starts with:
62
62
 
63
63
  - **Self-hosted Inter font** - variable `woff2` served from `public/fonts/`
64
64
  with `font-display: swap` and a preload hint; no third-party font requests
65
- - **Route-level code splitting** - pages load via `React.lazy` + `Suspense`
65
+ - **Route-level code splitting with preloading** - pages load via
66
+ `React.lazy` + `Suspense`; chunks preload on link hover/focus/touch and an
67
+ idle prefetcher warms the rest after first paint (documented in the
68
+ generated README, easy to remove)
66
69
  - **SEO-ready `index.html`** - meta description, Open Graph and Twitter card
67
70
  tags, theme-color, plus a `public/robots.txt`
68
71
  - **Accessible shell** - skip-to-content link, `<main>` landmark, visible
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-react-adam",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Create opinionated React apps with TypeScript, Vite, Wouter, and Tailwind CSS",
5
5
  "type": "module",
6
6
  "bin": {
@@ -53,10 +53,11 @@ Vite dev server is running, and prints diagnostic information.
53
53
  ```
54
54
  src/
55
55
  ├── pages/ # Route pages (Home, About, NotFound)
56
- ├── components/ # Reusable components
56
+ ├── components/ # Reusable components (Button, PreloadLink)
57
57
  ├── types/ # Shared TypeScript types
58
58
  ├── utils/ # Utility functions (if included)
59
59
  ├── App.tsx # Routing, skip link, <main> landmark
60
+ ├── routes.ts # Route import map + preload helpers
60
61
  ├── main.tsx # Entry point
61
62
  └── app.css # Tailwind theme + base styles
62
63
  ```
@@ -67,15 +68,55 @@ code-split with `React.lazy`, and each page sets its own `document.title`.
67
68
  ## Adding New Pages
68
69
 
69
70
  1. Create a new component in `src/pages/`
70
- 2. Add a lazy import and route in `src/App.tsx`
71
+ 2. Add its import thunk to `src/routes.ts` (this both registers it for
72
+ preloading and gives `App.tsx` its lazy import)
73
+ 3. Add a lazy component and route in `src/App.tsx`
71
74
 
72
75
  ```tsx
73
- const NewPage = lazy(() => import("./pages/NewPage"));
76
+ // src/routes.ts
77
+ export const routeImports: Record<string, PageLoader> = {
78
+ // ...
79
+ "/new": () => import("./pages/NewPage"),
80
+ };
81
+
82
+ // src/App.tsx
83
+ const NewPage = lazy(routeImports["/new"]);
74
84
 
75
85
  // In the Switch component:
76
86
  <Route path="/new" component={NewPage} />;
77
87
  ```
78
88
 
89
+ 4. Link to it with `<PreloadLink href="/new">` so the chunk preloads on
90
+ hover/focus/touch (wouter's plain `<Link>` works too, just without the
91
+ preload).
92
+
93
+ ## Route Preloading
94
+
95
+ Pages are lazy-loaded, so without preloading every navigation waits for a
96
+ network round trip. The template closes that gap in three layers:
97
+
98
+ 1. **One import map** (`src/routes.ts`): the router and the preloaders share
99
+ the same import thunks, and dynamic imports dedupe by module — so a
100
+ preloaded chunk is never downloaded twice.
101
+ 2. **Preload on intent** (`src/components/PreloadLink.tsx`): links fire the
102
+ target page's import on hover, focus, or touch. Hover-to-click is
103
+ typically 200-400ms — usually enough for the chunk to arrive first.
104
+ 3. **Idle backstop** (`preloadAllRoutesWhenIdle` in `src/routes.ts`, called
105
+ from `App.tsx`): after the window `load` event, remaining chunks are
106
+ fetched one at a time during idle periods. It never competes with
107
+ first-paint resources (so Lighthouse is unaffected) and skips users on
108
+ data-saver or 2G connections.
109
+
110
+ `App.tsx` also routes against a `useDeferredValue`-deferred location, so the
111
+ previous page stays painted until the next page is ready instead of flashing
112
+ the blank `Suspense` fallback. Preloading makes navigation fast; the deferred
113
+ location makes it smooth.
114
+
115
+ **To remove preloading**, follow the steps in the header comment of
116
+ `src/routes.ts` — in short: inline the imports back into `lazy()` in
117
+ `App.tsx`, swap `<PreloadLink>` back to wouter's `<Link>`, and delete
118
+ `src/routes.ts` and `src/components/PreloadLink.tsx`.
119
+
79
120
  ## Performance, SEO, and Accessibility
80
121
 
81
122
  - **Fonts**: Inter is self-hosted from `public/fonts/` (variable `woff2`,
@@ -95,7 +136,7 @@ and string literals. Convert images to WebP at
95
136
  For the rare image that must stay in another format:
96
137
 
97
138
  ```tsx
98
- // eslint-disable-next-line adam/prefer-webp-images
139
+ // eslint-disable-next-line adamjsturge/prefer-webp-images
99
140
  import legacyLogo from "./legacy-logo.png";
100
141
  ```
101
142
 
@@ -8,7 +8,7 @@ import reactHooks from "eslint-plugin-react-hooks";
8
8
  import reactRefresh from "eslint-plugin-react-refresh";
9
9
  import unicorn from "eslint-plugin-unicorn";
10
10
  import tseslint from "typescript-eslint";
11
- import adam from "./eslint-rules/prefer-webp-images.js";
11
+ import adamjsturge from "./eslint-rules/prefer-webp-images.js";
12
12
 
13
13
  export default tseslint.config({
14
14
  extends: [
@@ -26,7 +26,7 @@ export default tseslint.config({
26
26
  "jsx-a11y": jsxA11y,
27
27
  "react-hooks": reactHooks,
28
28
  "react-refresh": reactRefresh,
29
- adam,
29
+ adamjsturge,
30
30
  },
31
31
  languageOptions: {
32
32
  parserOptions: {
@@ -50,7 +50,7 @@ export default tseslint.config({
50
50
  "warn",
51
51
  { allowConstantExport: true },
52
52
  ],
53
- "adam/prefer-webp-images": "error",
53
+ "adamjsturge/prefer-webp-images": "error",
54
54
  },
55
55
  files: ["**/*.{ts,tsx}"],
56
56
  ignores: ["dist"],
@@ -1,31 +1,51 @@
1
- import { lazy, Suspense } from "react";
2
- import { Redirect, Route, Switch } from "wouter";
1
+ import { lazy, Suspense, useDeferredValue, useEffect } from "react";
2
+ import { Redirect, Route, Switch, useLocation } from "wouter";
3
+ import { preloadAllRoutesWhenIdle, routeImports } from "./routes";
3
4
 
4
- const Home = lazy(() => import("./pages/Home"));
5
- const About = lazy(() => import("./pages/About"));
6
- const NotFound = lazy(() => import("./pages/NotFound"));
5
+ // Pages are code-split: each chunk downloads on demand. The import thunks
6
+ // live in src/routes.ts so the preload helpers can warm the same chunks
7
+ // ahead of navigation (routes.ts documents how it works and how to remove).
8
+ const Home = lazy(routeImports["/"]);
9
+ const About = lazy(routeImports["/about"]);
10
+ const NotFound = lazy(routeImports["/404"]);
7
11
 
8
- const App = () => (
9
- <>
10
- <a
11
- href="#main-content"
12
- className="bg-brand-primary text-brand-white sr-only z-50 rounded-lg px-4 py-2 focus:not-sr-only focus:fixed focus:top-4 focus:left-4"
13
- >
14
- Skip to main content
15
- </a>
16
- <main id="main-content">
17
- <Suspense fallback={null}>
18
- <Switch>
19
- <Route path="/" component={Home} />
20
- <Route path="/about" component={About} />
21
- <Route path="/home">
22
- <Redirect to="/" />
23
- </Route>
24
- <Route component={NotFound} />
25
- </Switch>
26
- </Suspense>
27
- </main>
28
- </>
29
- );
12
+ const App = () => {
13
+ const [location] = useLocation();
14
+ // Routing against a deferred location means React keeps the previous page
15
+ // painted while the next page's chunk loads and initializes, instead of
16
+ // flashing the blank Suspense fallback. Preloading makes navigation fast;
17
+ // this makes it smooth. To remove: pass nothing to <Switch> below and
18
+ // delete these two hook calls.
19
+ const deferredLocation = useDeferredValue(location);
20
+
21
+ // Idle backstop — quietly fetch all remaining page chunks once the page
22
+ // has fully loaded. Delete this effect to remove (see src/routes.ts).
23
+ useEffect(() => {
24
+ preloadAllRoutesWhenIdle();
25
+ }, []);
26
+
27
+ return (
28
+ <>
29
+ <a
30
+ href="#main-content"
31
+ className="bg-brand-primary text-brand-white sr-only z-50 rounded-lg px-4 py-2 focus:not-sr-only focus:fixed focus:top-4 focus:left-4"
32
+ >
33
+ Skip to main content
34
+ </a>
35
+ <main id="main-content">
36
+ <Suspense fallback={null}>
37
+ <Switch location={deferredLocation}>
38
+ <Route path="/" component={Home} />
39
+ <Route path="/about" component={About} />
40
+ <Route path="/home">
41
+ <Redirect to="/" />
42
+ </Route>
43
+ <Route component={NotFound} />
44
+ </Switch>
45
+ </Suspense>
46
+ </main>
47
+ </>
48
+ );
49
+ };
30
50
 
31
51
  export default App;
@@ -0,0 +1,49 @@
1
+ import type { ComponentProps, FocusEvent, MouseEvent, TouchEvent } from "react";
2
+ import { Link } from "wouter";
3
+ import { preloadRoute } from "../routes";
4
+
5
+ // Drop-in replacement for wouter's <Link> that preloads the target page's
6
+ // chunk as soon as the user shows intent to navigate (hover, keyboard focus,
7
+ // or touch). By the time the click lands, the chunk is usually in flight or
8
+ // already cached, so navigation feels instant. See src/routes.ts for the
9
+ // full preloading picture.
10
+ //
11
+ // HOW TO REMOVE: swap <PreloadLink> back to wouter's <Link> wherever it is
12
+ // used (the props are identical), delete this file, and follow the removal
13
+ // steps in src/routes.ts.
14
+
15
+ // Mirrors wouter's <Link> props in its plain-anchor form. wouter's asChild
16
+ // variant and `to` alias are not supported here — use wouter's <Link>
17
+ // directly if you need those.
18
+ type PreloadLinkProps = Omit<ComponentProps<"a">, "className" | "href"> & {
19
+ href: string;
20
+ className?: string | ((isActive: boolean) => string | undefined);
21
+ replace?: boolean;
22
+ state?: unknown;
23
+ };
24
+
25
+ const PreloadLink = (props: PreloadLinkProps) => {
26
+ const handleIntent = () => {
27
+ preloadRoute(props.href);
28
+ };
29
+
30
+ return (
31
+ <Link
32
+ {...props}
33
+ onMouseEnter={(event: MouseEvent<HTMLAnchorElement>) => {
34
+ handleIntent();
35
+ props.onMouseEnter?.(event);
36
+ }}
37
+ onFocus={(event: FocusEvent<HTMLAnchorElement>) => {
38
+ handleIntent();
39
+ props.onFocus?.(event);
40
+ }}
41
+ onTouchStart={(event: TouchEvent<HTMLAnchorElement>) => {
42
+ handleIntent();
43
+ props.onTouchStart?.(event);
44
+ }}
45
+ />
46
+ );
47
+ };
48
+
49
+ export default PreloadLink;
@@ -1,5 +1,5 @@
1
1
  import { useEffect } from "react";
2
- import { Link } from "wouter";
2
+ import PreloadLink from "../../components/PreloadLink";
3
3
 
4
4
  const About = () => {
5
5
  useEffect(() => {
@@ -30,12 +30,12 @@ const About = () => {
30
30
  </ul>
31
31
  </div>
32
32
 
33
- <Link
33
+ <PreloadLink
34
34
  href="/"
35
35
  className="bg-brand-primary text-brand-white hover:bg-brand-primaryHover rounded-lg px-6 py-3 transition-colors"
36
36
  >
37
37
  Back to Home
38
- </Link>
38
+ </PreloadLink>
39
39
  </div>
40
40
  </div>
41
41
  );
@@ -1,6 +1,6 @@
1
1
  import { useEffect } from "react";
2
- import { Link } from "wouter";
3
2
  import Button from "../../components/Button";
3
+ import PreloadLink from "../../components/PreloadLink";
4
4
  import { useReactPersist } from "../../utils/Storage";
5
5
  import { useUrlState } from "../../utils/useUrlState";
6
6
 
@@ -93,12 +93,12 @@ const About = () => {
93
93
  <li>✨ ESLint & Prettier configured</li>
94
94
  </ul>
95
95
  </div>
96
- <Link
96
+ <PreloadLink
97
97
  href="/"
98
98
  className="bg-brand-primary text-brand-white hover:bg-brand-primaryHover rounded-lg px-6 py-3 transition-colors"
99
99
  >
100
100
  Back to Home
101
- </Link>
101
+ </PreloadLink>
102
102
  </div>
103
103
  </div>
104
104
  );
@@ -1,6 +1,6 @@
1
1
  import { useEffect, useState } from "react";
2
- import { Link } from "wouter";
3
2
  import Button from "../../components/Button";
3
+ import PreloadLink from "../../components/PreloadLink";
4
4
 
5
5
  const Home = () => {
6
6
  const [count, setCount] = useState(0);
@@ -66,12 +66,12 @@ const Home = () => {
66
66
  </div>
67
67
 
68
68
  <div className="flex justify-center gap-4">
69
- <Link
69
+ <PreloadLink
70
70
  href="/about"
71
71
  className="bg-brand-primary text-brand-white hover:bg-brand-primaryHover rounded-lg px-6 py-3 transition-colors"
72
72
  >
73
73
  About Page
74
- </Link>
74
+ </PreloadLink>
75
75
  <a
76
76
  href="https://react.dev"
77
77
  target="_blank"
@@ -1,6 +1,6 @@
1
1
  import { useEffect } from "react";
2
- import { Link } from "wouter";
3
2
  import Button from "../../components/Button";
3
+ import PreloadLink from "../../components/PreloadLink";
4
4
  import { useReactPersist } from "../../utils/Storage";
5
5
  import { useUrlState } from "../../utils/useUrlState";
6
6
 
@@ -81,12 +81,12 @@ const Home = () => {
81
81
  </div>
82
82
 
83
83
  <div className="flex justify-center gap-4">
84
- <Link
84
+ <PreloadLink
85
85
  href="/about"
86
86
  className="bg-brand-primary text-brand-white hover:bg-brand-primaryHover rounded-lg px-6 py-3 transition-colors"
87
87
  >
88
88
  About Page
89
- </Link>
89
+ </PreloadLink>
90
90
  <a
91
91
  href="https://react.dev"
92
92
  target="_blank"
@@ -1,5 +1,5 @@
1
1
  import { useEffect } from "react";
2
- import { Link } from "wouter";
2
+ import PreloadLink from "../../components/PreloadLink";
3
3
 
4
4
  const NotFound = () => {
5
5
  useEffect(() => {
@@ -16,12 +16,12 @@ const NotFound = () => {
16
16
  <p className="text-brand-gray mt-2">
17
17
  The page you're looking for doesn't exist.
18
18
  </p>
19
- <Link
19
+ <PreloadLink
20
20
  href="/"
21
21
  className="bg-brand-primary text-brand-white hover:bg-brand-primaryHover mt-6 inline-block rounded-lg px-6 py-3 transition-colors"
22
22
  >
23
23
  Go Home
24
- </Link>
24
+ </PreloadLink>
25
25
  </div>
26
26
  </div>
27
27
  );
@@ -0,0 +1,94 @@
1
+ import type { ComponentType } from "react";
2
+
3
+ // Route-level code splitting + preloading.
4
+ //
5
+ // This file is the single source of truth for every page's dynamic import.
6
+ // App.tsx feeds these thunks to React.lazy() for routing, and the preload
7
+ // helpers below fire the same thunks ahead of navigation. Because dynamic
8
+ // imports are deduped by module, a preloaded chunk is never downloaded
9
+ // twice — navigating just picks up the in-flight or already-cached module.
10
+ //
11
+ // Preloading happens in two layers:
12
+ // 1. Intent: <PreloadLink> (src/components/PreloadLink.tsx) calls
13
+ // preloadRoute() on hover/focus/touch. Hover-to-click is typically
14
+ // 200-400ms — enough for a small page chunk to arrive before the click.
15
+ // 2. Idle backstop: preloadAllRoutesWhenIdle() (called once from App.tsx)
16
+ // fetches every remaining chunk after the window `load` event, one at a
17
+ // time during idle periods, so it never competes with first-paint
18
+ // resources (or your Lighthouse score).
19
+ //
20
+ // HOW TO REMOVE PRELOADING:
21
+ // 1. In App.tsx: delete the preloadAllRoutesWhenIdle() effect and the
22
+ // useDeferredValue lines, and inline the imports back into lazy(),
23
+ // e.g. const Home = lazy(() => import("./pages/Home"));
24
+ // 2. In src/pages/: replace <PreloadLink> with wouter's <Link>.
25
+ // 3. Delete this file and src/components/PreloadLink.tsx.
26
+
27
+ type PageLoader = () => Promise<{ default: ComponentType }>;
28
+
29
+ export const routeImports: Record<string, PageLoader> = {
30
+ "/": () => import("./pages/Home"),
31
+ "/about": () => import("./pages/About"),
32
+ // Not a real link target — keyed by a pseudo-path so the idle prefetcher
33
+ // warms the 404 page too.
34
+ "/404": () => import("./pages/NotFound"),
35
+ };
36
+
37
+ // Fires a route's import ahead of navigation. Safe to call repeatedly and
38
+ // with paths that are not in the map (external links, dynamic segments) —
39
+ // those are a no-op.
40
+ export function preloadRoute(path: string): void {
41
+ const load = routeImports[path];
42
+ if (!load) return;
43
+ load().catch(() => {
44
+ // Preloading is opportunistic: if it fails (offline, a deploy replaced
45
+ // the chunk), the real import on navigation will surface the error.
46
+ });
47
+ }
48
+
49
+ let hasStartedIdlePreload = false;
50
+
51
+ // Fetches every route chunk after the page has fully loaded, one chunk at a
52
+ // time during browser idle periods. Skips entirely for users on data-saver
53
+ // or 2G connections.
54
+ export function preloadAllRoutesWhenIdle(): void {
55
+ // React StrictMode runs effects twice in development — only start once.
56
+ if (hasStartedIdlePreload) return;
57
+ hasStartedIdlePreload = true;
58
+
59
+ const { connection } = navigator as Navigator & {
60
+ connection?: { saveData?: boolean; effectiveType?: string };
61
+ };
62
+ if (connection?.saveData === true) return;
63
+ if (connection?.effectiveType?.endsWith("2g") === true) return;
64
+
65
+ // Safari has no requestIdleCallback; a timeout is a close-enough stand-in.
66
+ const scheduleIdle = (callback: () => void): void => {
67
+ if (typeof window.requestIdleCallback === "function") {
68
+ window.requestIdleCallback(callback);
69
+ } else {
70
+ window.setTimeout(callback, 1500);
71
+ }
72
+ };
73
+
74
+ const queue = Object.values(routeImports);
75
+ const loadNext = async (): Promise<void> => {
76
+ const load = queue.shift();
77
+ if (!load) return;
78
+ try {
79
+ await load();
80
+ } catch {
81
+ // Ignore failures here for the same reason as preloadRoute.
82
+ }
83
+ scheduleIdle(() => void loadNext());
84
+ };
85
+
86
+ // Waiting for `load` keeps prefetching out of the critical path: every
87
+ // first-paint resource is done before the first idle callback runs.
88
+ const start = (): void => scheduleIdle(() => void loadNext());
89
+ if (document.readyState === "complete") {
90
+ start();
91
+ } else {
92
+ window.addEventListener("load", start, { once: true });
93
+ }
94
+ }