diff --git a/.changeset/neat-socks-check.md b/.changeset/neat-socks-check.md
new file mode 100644
index 000000000..6f525fd6b
--- /dev/null
+++ b/.changeset/neat-socks-check.md
@@ -0,0 +1,5 @@
+---
+"vite-react-pwa": minor
+---
+
+Added vite-react-pwa example
diff --git a/examples/vite-react-pwa/.eslintrc.cjs b/examples/vite-react-pwa/.eslintrc.cjs
new file mode 100644
index 000000000..d6c953795
--- /dev/null
+++ b/examples/vite-react-pwa/.eslintrc.cjs
@@ -0,0 +1,18 @@
+module.exports = {
+ root: true,
+ env: { browser: true, es2020: true },
+ extends: [
+ 'eslint:recommended',
+ 'plugin:@typescript-eslint/recommended',
+ 'plugin:react-hooks/recommended',
+ ],
+ ignorePatterns: ['dist', '.eslintrc.cjs'],
+ parser: '@typescript-eslint/parser',
+ plugins: ['react-refresh'],
+ rules: {
+ 'react-refresh/only-export-components': [
+ 'warn',
+ { allowConstantExport: true },
+ ],
+ },
+}
diff --git a/examples/vite-react-pwa/.gitignore b/examples/vite-react-pwa/.gitignore
new file mode 100644
index 000000000..6d6ae5aca
--- /dev/null
+++ b/examples/vite-react-pwa/.gitignore
@@ -0,0 +1,25 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+dev-dist
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/examples/vite-react-pwa/README.md b/examples/vite-react-pwa/README.md
new file mode 100644
index 000000000..0d6babedd
--- /dev/null
+++ b/examples/vite-react-pwa/README.md
@@ -0,0 +1,30 @@
+# React + TypeScript + Vite
+
+This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
+
+Currently, two official plugins are available:
+
+- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
+- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
+
+## Expanding the ESLint configuration
+
+If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
+
+- Configure the top-level `parserOptions` property like this:
+
+```js
+export default {
+ // other rules...
+ parserOptions: {
+ ecmaVersion: 'latest',
+ sourceType: 'module',
+ project: ['./tsconfig.json', './tsconfig.node.json'],
+ tsconfigRootDir: __dirname,
+ },
+}
+```
+
+- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
+- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
+- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
diff --git a/examples/vite-react-pwa/index.html b/examples/vite-react-pwa/index.html
new file mode 100644
index 000000000..7b6e4c31f
--- /dev/null
+++ b/examples/vite-react-pwa/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ vite-react-pwa + TS
+
+
+
+
+
+
diff --git a/examples/vite-react-pwa/package.json b/examples/vite-react-pwa/package.json
new file mode 100644
index 000000000..2a418c3b5
--- /dev/null
+++ b/examples/vite-react-pwa/package.json
@@ -0,0 +1,36 @@
+{
+ "name": "vite-react-pwa",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@evolu/react": "^8.0.2",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1"
+ },
+ "devDependencies": {
+ "@types/react": "^18.3.1",
+ "@types/react-dom": "^18.3.0",
+ "@typescript-eslint/eslint-plugin": "^7.8.0",
+ "@typescript-eslint/parser": "^7.8.0",
+ "@vite-pwa/assets-generator": "^0.2.4",
+ "@vitejs/plugin-react": "^4.2.1",
+ "eslint": "^8.57.0",
+ "eslint-plugin-react-hooks": "^4.6.2",
+ "eslint-plugin-react-refresh": "^0.4.6",
+ "typescript": "^5.2.2",
+ "vite": "^5.2.10",
+ "vite-plugin-pwa": "^0.20.0",
+ "workbox-core": "^7.1.0"
+ },
+ "resolutions": {
+ "sharp": "0.32.6",
+ "sharp-ico": "0.1.5"
+ }
+}
diff --git a/examples/vite-react-pwa/public/favicon.svg b/examples/vite-react-pwa/public/favicon.svg
new file mode 100644
index 000000000..733f4fb45
--- /dev/null
+++ b/examples/vite-react-pwa/public/favicon.svg
@@ -0,0 +1,130 @@
+
+
+
+
+
+ image/svg+xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/vite-react-pwa/pwa-assets.config.ts b/examples/vite-react-pwa/pwa-assets.config.ts
new file mode 100644
index 000000000..452b31fa6
--- /dev/null
+++ b/examples/vite-react-pwa/pwa-assets.config.ts
@@ -0,0 +1,12 @@
+import {
+ defineConfig,
+ minimal2023Preset as preset,
+} from '@vite-pwa/assets-generator/config'
+
+export default defineConfig({
+ headLinkOptions: {
+ preset: '2023',
+ },
+ preset,
+ images: ['public/favicon.svg'],
+})
diff --git a/examples/vite-react-pwa/src/App.css b/examples/vite-react-pwa/src/App.css
new file mode 100644
index 000000000..92de7e348
--- /dev/null
+++ b/examples/vite-react-pwa/src/App.css
@@ -0,0 +1,50 @@
+#root {
+ max-width: 1280px;
+ margin: 0 auto;
+ padding: 2rem;
+ text-align: center;
+}
+
+.logo {
+ height: 6em;
+ padding: 1.5em;
+ will-change: filter;
+ transition: filter 300ms;
+}
+.logo:hover {
+ filter: drop-shadow(0 0 2em #646cffaa);
+}
+.logo.react:hover {
+ filter: drop-shadow(0 0 2em #61dafbaa);
+}
+
+@keyframes logo-spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ a:nth-of-type(2) .logo {
+ animation: logo-spin infinite 20s linear;
+ }
+}
+
+.card {
+ padding: 2em;
+}
+
+.read-the-docs {
+ color: #888;
+}
+
+button {
+ margin: 0 0.4em;
+}
+
+li {
+ margin: 0.5em 0;
+}
diff --git a/examples/vite-react-pwa/src/App.tsx b/examples/vite-react-pwa/src/App.tsx
new file mode 100644
index 000000000..ecb9bf879
--- /dev/null
+++ b/examples/vite-react-pwa/src/App.tsx
@@ -0,0 +1,42 @@
+import { useState } from "react";
+import "./App.css";
+import ViteExample from "./EvoluDemo.tsx";
+import PWABadge from "./PWABadge.tsx";
+import reactLogo from "./assets/react.svg";
+import appLogo from "/favicon.svg";
+
+function App() {
+ const [count, setCount] = useState(0);
+
+ return (
+ <>
+
+ vite-react-pwa
+
+
setCount((count) => count + 1)}>
+ count is {count}
+
+
+ Edit src/App.tsx
and save to test HMR
+
+
+
+
+
+
+ >
+ );
+}
+
+export default App;
diff --git a/examples/vite-react-pwa/src/EvoluDemo.tsx b/examples/vite-react-pwa/src/EvoluDemo.tsx
new file mode 100644
index 000000000..3b3e04c70
--- /dev/null
+++ b/examples/vite-react-pwa/src/EvoluDemo.tsx
@@ -0,0 +1,434 @@
+import * as S from "@effect/schema/Schema";
+import { formatError } from "@effect/schema/TreeFormatter";
+import {
+ EvoluProvider,
+ ExtractRow,
+ NonEmptyString1000,
+ NotNull,
+ SqliteBoolean,
+ String,
+ cast,
+ createEvolu,
+ createIndexes,
+ database,
+ id,
+ jsonArrayFrom,
+ parseMnemonic,
+ table,
+ useEvolu,
+ useEvoluError,
+ useOwner,
+ useQuery,
+} from "@evolu/react";
+import { Effect, Exit } from "effect";
+import {
+ ChangeEvent,
+ FC,
+ Suspense,
+ memo,
+ startTransition,
+ useEffect,
+ useState,
+} from "react";
+
+// Let's start with the database schema.
+
+// Every table needs Id. It's defined as a branded type.
+// Branded types make database types super safe.
+const TodoId = id("Todo");
+type TodoId = typeof TodoId.Type;
+
+const TodoCategoryId = id("TodoCategory");
+type TodoCategoryId = typeof TodoCategoryId.Type;
+
+// This branded type ensures a string must be validated before being put
+// into the database. Check the prompt function to see Schema validation.
+const NonEmptyString50 = String.pipe(
+ S.minLength(1),
+ S.maxLength(50),
+ S.brand("NonEmptyString50"),
+);
+type NonEmptyString50 = typeof NonEmptyString50.Type;
+
+// Now we can define tables.
+const TodoTable = table({
+ id: TodoId,
+ title: NonEmptyString1000,
+ isCompleted: S.NullOr(SqliteBoolean),
+ categoryId: S.NullOr(TodoCategoryId),
+});
+type TodoTable = typeof TodoTable.Type;
+
+// Evolu tables can contain typed JSONs.
+const SomeJson = S.Struct({ foo: S.String, bar: S.Boolean });
+type SomeJson = typeof SomeJson.Type;
+
+// Let's make a table with JSON value.
+const TodoCategoryTable = table({
+ id: TodoCategoryId,
+ name: NonEmptyString50,
+ json: S.NullOr(SomeJson),
+});
+type TodoCategoryTable = typeof TodoCategoryTable.Type;
+
+// Now, we can define the database schema.
+const Database = database({
+ todo: TodoTable,
+ todoCategory: TodoCategoryTable,
+});
+type Database = typeof Database.Type;
+
+/**
+ * Indexes are not necessary for development but are required for production.
+ * Before adding an index, use `logExecutionTime` and `logExplainQueryPlan`
+ * createQuery options.
+ *
+ * See https://www.evolu.dev/docs/indexes
+ */
+const indexes = createIndexes((create) => [
+ create("indexTodoCreatedAt").on("todo").column("createdAt"),
+ create("indexTodoCategoryCreatedAt").on("todoCategory").column("createdAt"),
+]);
+
+const evolu = createEvolu(Database, {
+ indexes,
+ initialData: (evolu) => {
+ const { id: categoryId } = evolu.create("todoCategory", {
+ name: S.decodeSync(NonEmptyString50)("Not Urgent"),
+ });
+ evolu.create("todo", {
+ title: S.decodeSync(NonEmptyString1000)("Try React Suspense"),
+ categoryId,
+ });
+ },
+});
+
+const ViteExample = memo(function ViteExample() {
+ const [currentTab, setCurrentTab] = useState<"todos" | "categories">("todos");
+
+ const handleTabClick = () =>
+ // https://react.dev/reference/react/useTransition#building-a-suspense-enabled-router
+ startTransition(() => {
+ setCurrentTab(currentTab === "todos" ? "categories" : "todos");
+ });
+
+ return (
+
+
+
+
+ {currentTab === "todos" ? "Todos" : "Categories"}
+
+ {currentTab === "todos" ? : }
+
+
+ To try React Suspense, click the `Switch Tab` button and rename a
+ category. Then click the `Switch Tab` again to see the updated
+ category name without any loading state. React Suspense is excellent
+ for UX.
+
+
+ The data created in this example are stored locally in SQLite. Evolu
+ encrypts the data for backup and sync with a Mnemonic, a unique safe
+ password created on your device.
+
+
+
+
+ );
+});
+
+const NotificationBar: FC = () => {
+ const evoluError = useEvoluError();
+ const [showError, setShowError] = useState(false);
+
+ useEffect(() => {
+ if (evoluError) setShowError(true);
+ }, [evoluError]);
+
+ if (!evoluError || !showError) return null;
+
+ return (
+
+
{`Error: ${JSON.stringify(evoluError)}`}
+
setShowError(false)} />
+
+ );
+};
+
+// Evolu queries should be collocated. If necessary, they can be preloaded.
+const todosWithCategories = evolu.createQuery(
+ (db) =>
+ db
+ .selectFrom("todo")
+ .select(["id", "title", "isCompleted", "categoryId"])
+ .where("isDeleted", "is not", cast(true))
+ // Filter null value and ensure non-null type.
+ .where("title", "is not", null)
+ .$narrowType<{ title: NotNull }>()
+ .orderBy("createdAt")
+ // https://kysely.dev/docs/recipes/relations
+ .select((eb) => [
+ jsonArrayFrom(
+ eb
+ .selectFrom("todoCategory")
+ .select(["todoCategory.id", "todoCategory.name"])
+ .where("isDeleted", "is not", cast(true))
+ .orderBy("createdAt"),
+ ).as("categories"),
+ ]),
+ {
+ // logQueryExecutionTime: true,
+ // logExplainQueryPlan: true,
+ },
+);
+
+type TodosWithCategoriesRow = ExtractRow;
+
+const Todos: FC = () => {
+ const { rows } = useQuery(todosWithCategories);
+ const { create } = useEvolu();
+
+ const handleAddTodoClick = () => {
+ prompt(NonEmptyString1000, "What needs to be done?", (title) => {
+ create("todo", { title });
+ });
+ };
+
+ return (
+ <>
+
+ {rows.map((row) => (
+
+ ))}
+
+
+ >
+ );
+};
+
+const TodoItem = memo<{
+ row: TodosWithCategoriesRow;
+}>(function TodoItem({
+ row: { id, title, isCompleted, categoryId, categories },
+}) {
+ const { update } = useEvolu();
+
+ const handleToggleCompletedClick = () => {
+ update("todo", { id, isCompleted: !isCompleted });
+ };
+
+ const handleRenameClick = () => {
+ prompt(NonEmptyString1000, "New Name", (title) => {
+ update("todo", { id, title });
+ });
+ };
+
+ const handleDeleteClick = () => {
+ update("todo", { id, isDeleted: true });
+ };
+
+ return (
+
+
+ {title}
+
+
+
+
+ {
+ update("todo", { id, categoryId });
+ }}
+ />
+
+ );
+});
+
+const TodoCategorySelect: FC<{
+ categories: TodosWithCategoriesRow["categories"];
+ selected: TodoCategoryId | null;
+ onSelect: (value: TodoCategoryId | null) => void;
+}> = ({ categories, selected, onSelect }) => {
+ const nothingSelected = "";
+ const value =
+ selected && categories.find((row) => row.id === selected)
+ ? selected
+ : nothingSelected;
+
+ return (
+ ) => {
+ onSelect(value === nothingSelected ? null : (value as TodoCategoryId));
+ }}
+ >
+ -- no category --
+ {categories.map(({ id, name }) => (
+
+ {name}
+
+ ))}
+
+ );
+};
+
+const todoCategories = evolu.createQuery((db) =>
+ db
+ .selectFrom("todoCategory")
+ .select(["id", "name", "json"])
+ .where("isDeleted", "is not", cast(true))
+ // Filter null value and ensure non-null type.
+ .where("name", "is not", null)
+ .$narrowType<{ name: NotNull }>()
+ .orderBy("createdAt"),
+);
+
+type TodoCategoriesRow = ExtractRow;
+
+const TodoCategories: FC = () => {
+ const { rows } = useQuery(todoCategories);
+ const { create } = useEvolu();
+
+ // Evolu automatically parses JSONs into typed objects.
+ // if (rows[0]) console.log(rows[1].json?.foo);
+
+ const handleAddCategoryClick = () => {
+ prompt(NonEmptyString50, "Category Name", (name) => {
+ create("todoCategory", {
+ name,
+ json: { foo: "a", bar: false },
+ });
+ });
+ };
+
+ return (
+ <>
+
+ {rows.map((row) => (
+
+ ))}
+
+
+ >
+ );
+};
+
+const TodoCategoryItem = memo<{
+ row: TodoCategoriesRow;
+}>(function TodoItem({ row: { id, name } }) {
+ const { update } = useEvolu();
+
+ const handleRenameClick = () => {
+ prompt(NonEmptyString50, "Category Name", (name) => {
+ update("todoCategory", { id, name });
+ });
+ };
+
+ const handleDeleteClick = () => {
+ update("todoCategory", { id, isDeleted: true });
+ };
+
+ return (
+ <>
+
+ {name}
+
+
+
+ >
+ );
+});
+
+const OwnerActions: FC = () => {
+ const evolu = useEvolu();
+ const owner = useOwner();
+ const [showMnemonic, setShowMnemonic] = useState(false);
+
+ const handleRestoreOwnerClick = () => {
+ prompt(NonEmptyString1000, "Your Mnemonic", (mnemonic) => {
+ parseMnemonic(mnemonic)
+ .pipe(Effect.runPromiseExit)
+ .then(
+ Exit.match({
+ onFailure: (error) => {
+ alert(JSON.stringify(error, null, 2));
+ },
+ onSuccess: (mnemonic) => {
+ evolu.restoreOwner(mnemonic);
+ },
+ }),
+ );
+ });
+ };
+
+ const handleResetOwnerClick = () => {
+ if (confirm("Are you sure? It will delete all your local data.")) {
+ evolu.resetOwner();
+ }
+ };
+
+ return (
+
+
+ Open this page on a different device and use your mnemonic to restore
+ your data.
+
+
setShowMnemonic(!showMnemonic)}
+ />
+
+
+ {showMnemonic && owner != null && (
+
+
+
+ )}
+
+ );
+};
+
+const Button: FC<{
+ title: string;
+ onClick: () => void;
+}> = ({ title, onClick }) => {
+ return (
+
+ {title}
+
+ );
+};
+
+const prompt = (
+ schema: S.Schema,
+ message: string,
+ onSuccess: (value: To) => void,
+) => {
+ const value = window.prompt(message);
+ if (value == null) return; // on cancel
+ const a = S.decodeUnknownEither(schema)(value);
+ if (a._tag === "Left") {
+ alert(formatError(a.left));
+ return;
+ }
+ onSuccess(a.right);
+};
+
+export default ViteExample;
diff --git a/examples/vite-react-pwa/src/PWABadge.css b/examples/vite-react-pwa/src/PWABadge.css
new file mode 100644
index 000000000..4ed19fabc
--- /dev/null
+++ b/examples/vite-react-pwa/src/PWABadge.css
@@ -0,0 +1,29 @@
+.PWABadge-container {
+ padding: 0;
+ margin: 0;
+ width: 0;
+ height: 0;
+}
+.PWABadge-toast {
+ position: fixed;
+ right: 0;
+ bottom: 0;
+ margin: 16px;
+ padding: 12px;
+ border: 1px solid #8885;
+ border-radius: 4px;
+ z-index: 1;
+ text-align: left;
+ box-shadow: 3px 4px 5px 0 #8885;
+ background-color: white;
+}
+.PWABadge-toast-message {
+ margin-bottom: 8px;
+}
+.PWABadge-toast-button {
+ border: 1px solid #8885;
+ outline: none;
+ margin-right: 5px;
+ border-radius: 2px;
+ padding: 3px 10px;
+}
diff --git a/examples/vite-react-pwa/src/PWABadge.tsx b/examples/vite-react-pwa/src/PWABadge.tsx
new file mode 100644
index 000000000..caacb66eb
--- /dev/null
+++ b/examples/vite-react-pwa/src/PWABadge.tsx
@@ -0,0 +1,77 @@
+import './PWABadge.css'
+
+import { useRegisterSW } from 'virtual:pwa-register/react'
+
+function PWABadge() {
+ // check for updates every hour
+ const period = 60 * 60 * 1000
+
+ const {
+ offlineReady: [offlineReady, setOfflineReady],
+ needRefresh: [needRefresh, setNeedRefresh],
+ updateServiceWorker,
+ } = useRegisterSW({
+ onRegisteredSW(swUrl, r) {
+ if (period <= 0) return
+ if (r?.active?.state === 'activated') {
+ registerPeriodicSync(period, swUrl, r)
+ }
+ else if (r?.installing) {
+ r.installing.addEventListener('statechange', (e) => {
+ const sw = e.target as ServiceWorker
+ if (sw.state === 'activated')
+ registerPeriodicSync(period, swUrl, r)
+ })
+ }
+ },
+ })
+
+ function close() {
+ setOfflineReady(false)
+ setNeedRefresh(false)
+ }
+
+ return (
+
+ { (offlineReady || needRefresh)
+ && (
+
+
+ { offlineReady
+ ? App ready to work offline
+ : New content available, click on reload button to update. }
+
+
+ { needRefresh && updateServiceWorker(true)}>Reload }
+ close()}>Close
+
+
+ )}
+
+ )
+}
+
+export default PWABadge
+
+/**
+ * This function will register a periodic sync check every hour, you can modify the interval as needed.
+ */
+function registerPeriodicSync(period: number, swUrl: string, r: ServiceWorkerRegistration) {
+ if (period <= 0) return
+
+ setInterval(async () => {
+ if ('onLine' in navigator && !navigator.onLine)
+ return
+
+ const resp = await fetch(swUrl, {
+ cache: 'no-store',
+ headers: {
+ 'cache': 'no-store',
+ 'cache-control': 'no-cache',
+ },
+ })
+
+ if (resp?.status === 200)
+ await r.update()
+ }, period)
+}
diff --git a/examples/vite-react-pwa/src/assets/react.svg b/examples/vite-react-pwa/src/assets/react.svg
new file mode 100644
index 000000000..6c87de9bb
--- /dev/null
+++ b/examples/vite-react-pwa/src/assets/react.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/examples/vite-react-pwa/src/index.css b/examples/vite-react-pwa/src/index.css
new file mode 100644
index 000000000..6119ad9a8
--- /dev/null
+++ b/examples/vite-react-pwa/src/index.css
@@ -0,0 +1,68 @@
+:root {
+ font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
+ line-height: 1.5;
+ font-weight: 400;
+
+ color-scheme: light dark;
+ color: rgba(255, 255, 255, 0.87);
+ background-color: #242424;
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+a {
+ font-weight: 500;
+ color: #646cff;
+ text-decoration: inherit;
+}
+a:hover {
+ color: #535bf2;
+}
+
+body {
+ margin: 0;
+ display: flex;
+ place-items: center;
+ min-width: 320px;
+ min-height: 100vh;
+}
+
+h1 {
+ font-size: 3.2em;
+ line-height: 1.1;
+}
+
+button {
+ border-radius: 8px;
+ border: 1px solid transparent;
+ padding: 0.6em 1.2em;
+ font-size: 1em;
+ font-weight: 500;
+ font-family: inherit;
+ background-color: #1a1a1a;
+ cursor: pointer;
+ transition: border-color 0.25s;
+}
+button:hover {
+ border-color: #646cff;
+}
+button:focus,
+button:focus-visible {
+ outline: 4px auto -webkit-focus-ring-color;
+}
+
+@media (prefers-color-scheme: light) {
+ :root {
+ color: #213547;
+ background-color: #ffffff;
+ }
+ a:hover {
+ color: #747bff;
+ }
+ button {
+ background-color: #f9f9f9;
+ }
+}
diff --git a/examples/vite-react-pwa/src/main.tsx b/examples/vite-react-pwa/src/main.tsx
new file mode 100644
index 000000000..3d7150da8
--- /dev/null
+++ b/examples/vite-react-pwa/src/main.tsx
@@ -0,0 +1,10 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import App from './App.tsx'
+import './index.css'
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+ ,
+)
diff --git a/examples/vite-react-pwa/src/vite-env.d.ts b/examples/vite-react-pwa/src/vite-env.d.ts
new file mode 100644
index 000000000..ec878b71d
--- /dev/null
+++ b/examples/vite-react-pwa/src/vite-env.d.ts
@@ -0,0 +1,2 @@
+///
+///
diff --git a/examples/vite-react-pwa/tsconfig.json b/examples/vite-react-pwa/tsconfig.json
new file mode 100644
index 000000000..a7fc6fbf2
--- /dev/null
+++ b/examples/vite-react-pwa/tsconfig.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src"],
+ "references": [{ "path": "./tsconfig.node.json" }]
+}
diff --git a/examples/vite-react-pwa/tsconfig.node.json b/examples/vite-react-pwa/tsconfig.node.json
new file mode 100644
index 000000000..97ede7ee6
--- /dev/null
+++ b/examples/vite-react-pwa/tsconfig.node.json
@@ -0,0 +1,11 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true,
+ "strict": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/examples/vite-react-pwa/vite.config.ts b/examples/vite-react-pwa/vite.config.ts
new file mode 100644
index 000000000..4ca57acb2
--- /dev/null
+++ b/examples/vite-react-pwa/vite.config.ts
@@ -0,0 +1,52 @@
+import react from "@vitejs/plugin-react";
+import { defineConfig } from "vite";
+import { VitePWA } from "vite-plugin-pwa";
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ optimizeDeps: {
+ exclude: [
+ // Do not pre-bundle `@evolu/common-web`. Why? Worker relative import path resolution fails on registration because `@evolu/common-web/dist/*.worker.js`
+ // is not present in Vite's pre-bundled dependencies cache (typically `node_modules/.vite/deps`).
+ // @see https://github.com/vitejs/vite/issues/13314#issuecomment-1560745780
+ "@evolu/common-web",
+ ],
+ // Another workaround for Vite bug: https://github.com/radix-ui/primitives/discussions/1915#discussioncomment-5733178
+ include: ["react-dom"],
+ },
+ worker: {
+ format: "es",
+ },
+ plugins: [
+ react(),
+ VitePWA({
+ registerType: "autoUpdate",
+ injectRegister: false,
+
+ pwaAssets: {
+ disabled: false,
+ config: true,
+ },
+
+ manifest: {
+ name: "vite-react-pwa",
+ short_name: "vite-react-pwa",
+ description: "vite-react-pwa",
+ theme_color: "#ffffff",
+ },
+
+ workbox: {
+ globPatterns: ["**/*.{js,css,html,svg,png,ico}"],
+ cleanupOutdatedCaches: true,
+ clientsClaim: true,
+ },
+
+ devOptions: {
+ enabled: true,
+ navigateFallback: "index.html",
+ suppressWarnings: true,
+ type: "module",
+ },
+ }),
+ ],
+});