create-react-adam 0.3.0 → 0.5.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 +4 -1
- package/package.json +1 -1
- package/template/.github/workflows/check.yml +1 -1
- package/template/.github/workflows/e2e.yml +3 -3
- package/template/.github/workflows/lighthouse.yml +1 -1
- package/template/README.md +45 -4
- package/template/eslint.config.js +4 -4
- package/template/eslint.config.no-webp.js +1 -1
- package/template/package.json +13 -7
- package/template/src/App.tsx +47 -27
- package/template/src/components/PreloadLink.tsx +49 -0
- package/template/src/pages/About/index.no-utils.tsx +3 -3
- package/template/src/pages/About/index.tsx +3 -3
- package/template/src/pages/Home/index.no-utils.tsx +3 -3
- package/template/src/pages/Home/index.tsx +3 -3
- package/template/src/pages/NotFound/index.tsx +3 -3
- package/template/src/routes.ts +98 -0
- package/template/src/utils/useUrlState.ts +1 -1
- package/template/vite.config.ts +0 -2
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
|
|
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
|
@@ -12,7 +12,7 @@ jobs:
|
|
|
12
12
|
runs-on: ubuntu-latest
|
|
13
13
|
|
|
14
14
|
steps:
|
|
15
|
-
- uses: actions/checkout@
|
|
15
|
+
- uses: actions/checkout@v7
|
|
16
16
|
|
|
17
17
|
- name: Setup Node.js
|
|
18
18
|
uses: actions/setup-node@v6
|
|
@@ -36,7 +36,7 @@ jobs:
|
|
|
36
36
|
run: npm run test:e2e
|
|
37
37
|
|
|
38
38
|
- name: Upload Playwright report
|
|
39
|
-
uses: actions/upload-artifact@
|
|
39
|
+
uses: actions/upload-artifact@v7
|
|
40
40
|
if: always()
|
|
41
41
|
with:
|
|
42
42
|
name: playwright-report
|
|
@@ -44,7 +44,7 @@ jobs:
|
|
|
44
44
|
retention-days: 30
|
|
45
45
|
|
|
46
46
|
- name: Upload Allure results
|
|
47
|
-
uses: actions/upload-artifact@
|
|
47
|
+
uses: actions/upload-artifact@v7
|
|
48
48
|
if: always()
|
|
49
49
|
with:
|
|
50
50
|
name: allure-results
|
package/template/README.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
139
|
+
// eslint-disable-next-line adamjsturge/prefer-webp-images
|
|
99
140
|
import legacyLogo from "./legacy-logo.png";
|
|
100
141
|
```
|
|
101
142
|
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import eslint from "@eslint/js";
|
|
2
2
|
import prettier from "eslint-config-prettier";
|
|
3
3
|
import deMorgan from "eslint-plugin-de-morgan";
|
|
4
|
-
import importPlugin from "eslint-plugin-import";
|
|
4
|
+
import importPlugin from "eslint-plugin-import-x";
|
|
5
5
|
import jsxA11y from "eslint-plugin-jsx-a11y";
|
|
6
6
|
import promisePlugin from "eslint-plugin-promise";
|
|
7
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
|
|
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
|
-
|
|
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
|
-
"
|
|
53
|
+
"adamjsturge/prefer-webp-images": "error",
|
|
54
54
|
},
|
|
55
55
|
files: ["**/*.{ts,tsx}"],
|
|
56
56
|
ignores: ["dist"],
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import eslint from "@eslint/js";
|
|
2
2
|
import prettier from "eslint-config-prettier";
|
|
3
3
|
import deMorgan from "eslint-plugin-de-morgan";
|
|
4
|
-
import importPlugin from "eslint-plugin-import";
|
|
4
|
+
import importPlugin from "eslint-plugin-import-x";
|
|
5
5
|
import jsxA11y from "eslint-plugin-jsx-a11y";
|
|
6
6
|
import promisePlugin from "eslint-plugin-promise";
|
|
7
7
|
import reactHooks from "eslint-plugin-react-hooks";
|
package/template/package.json
CHANGED
|
@@ -25,26 +25,32 @@
|
|
|
25
25
|
"wouter": "3.10.0"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
|
-
"@eslint/js": "
|
|
28
|
+
"@eslint/js": "10.0.1",
|
|
29
29
|
"@tailwindcss/vite": "4.3.2",
|
|
30
30
|
"@types/react": "19.2.17",
|
|
31
31
|
"@types/react-dom": "19.2.3",
|
|
32
|
-
"@vitejs/plugin-react": "
|
|
33
|
-
"eslint": "
|
|
32
|
+
"@vitejs/plugin-react": "6.0.3",
|
|
33
|
+
"eslint": "10.6.0",
|
|
34
34
|
"eslint-config-prettier": "10.1.8",
|
|
35
|
+
"eslint-import-resolver-typescript": "4.4.5",
|
|
35
36
|
"eslint-plugin-de-morgan": "2.1.2",
|
|
36
|
-
"eslint-plugin-import": "
|
|
37
|
+
"eslint-plugin-import-x": "4.17.1",
|
|
37
38
|
"eslint-plugin-jsx-a11y": "6.10.2",
|
|
38
39
|
"eslint-plugin-promise": "7.3.0",
|
|
39
40
|
"eslint-plugin-react-hooks": "7.1.1",
|
|
40
41
|
"eslint-plugin-react-refresh": "0.5.3",
|
|
41
|
-
"eslint-plugin-unicorn": "
|
|
42
|
+
"eslint-plugin-unicorn": "71.1.0",
|
|
42
43
|
"prettier": "3.9.5",
|
|
43
44
|
"prettier-plugin-organize-imports": "4.3.0",
|
|
44
45
|
"prettier-plugin-tailwindcss": "0.8.0",
|
|
45
46
|
"tailwindcss": "4.3.2",
|
|
46
|
-
"typescript": "
|
|
47
|
+
"typescript": "6.0.3",
|
|
47
48
|
"typescript-eslint": "8.63.0",
|
|
48
|
-
"vite": "
|
|
49
|
+
"vite": "8.1.4"
|
|
50
|
+
},
|
|
51
|
+
"overrides": {
|
|
52
|
+
"eslint-plugin-jsx-a11y": {
|
|
53
|
+
"eslint": "$eslint"
|
|
54
|
+
}
|
|
49
55
|
}
|
|
50
56
|
}
|
package/template/src/App.tsx
CHANGED
|
@@ -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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
|
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
|
-
<
|
|
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
|
-
</
|
|
24
|
+
</PreloadLink>
|
|
25
25
|
</div>
|
|
26
26
|
</div>
|
|
27
27
|
);
|
|
@@ -0,0 +1,98 @@
|
|
|
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
|
+
void (async () => {
|
|
44
|
+
try {
|
|
45
|
+
await load();
|
|
46
|
+
} catch {
|
|
47
|
+
// Preloading is opportunistic: if it fails (offline, a deploy replaced
|
|
48
|
+
// the chunk), the real import on navigation will surface the error.
|
|
49
|
+
}
|
|
50
|
+
})();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let hasStartedIdlePreload = false;
|
|
54
|
+
|
|
55
|
+
// Fetches every route chunk after the page has fully loaded, one chunk at a
|
|
56
|
+
// time during browser idle periods. Skips entirely for users on data-saver
|
|
57
|
+
// or 2G connections.
|
|
58
|
+
export function preloadAllRoutesWhenIdle(): void {
|
|
59
|
+
// React StrictMode runs effects twice in development — only start once.
|
|
60
|
+
if (hasStartedIdlePreload) return;
|
|
61
|
+
hasStartedIdlePreload = true;
|
|
62
|
+
|
|
63
|
+
const { connection } = navigator as Navigator & {
|
|
64
|
+
connection?: { saveData?: boolean; effectiveType?: string };
|
|
65
|
+
};
|
|
66
|
+
if (connection?.saveData === true) return;
|
|
67
|
+
if (connection?.effectiveType?.endsWith("2g") === true) return;
|
|
68
|
+
|
|
69
|
+
// Safari has no requestIdleCallback; a timeout is a close-enough stand-in.
|
|
70
|
+
const scheduleIdle = (callback: () => void): void => {
|
|
71
|
+
if (typeof globalThis.requestIdleCallback === "function") {
|
|
72
|
+
globalThis.requestIdleCallback(callback);
|
|
73
|
+
} else {
|
|
74
|
+
globalThis.setTimeout(callback, 1500);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const queue = Object.values(routeImports);
|
|
79
|
+
const loadNext = async (): Promise<void> => {
|
|
80
|
+
const load = queue.shift();
|
|
81
|
+
if (!load) return;
|
|
82
|
+
try {
|
|
83
|
+
await load();
|
|
84
|
+
} catch {
|
|
85
|
+
// Ignore failures here for the same reason as preloadRoute.
|
|
86
|
+
}
|
|
87
|
+
scheduleIdle(() => void loadNext());
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// Waiting for `load` keeps prefetching out of the critical path: every
|
|
91
|
+
// first-paint resource is done before the first idle callback runs.
|
|
92
|
+
const start = (): void => scheduleIdle(() => void loadNext());
|
|
93
|
+
if (document.readyState === "complete") {
|
|
94
|
+
start();
|
|
95
|
+
} else {
|
|
96
|
+
window.addEventListener("load", start, { once: true });
|
|
97
|
+
}
|
|
98
|
+
}
|
package/template/vite.config.ts
CHANGED