snapshot of https://github.com/TanStack/query.git (main, 2026-09-16; blobs > 131072B omitted)
This commit is contained in:
3
examples/react/offline/.eslintrc
Normal file
3
examples/react/offline/.eslintrc
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": ["plugin:react/jsx-runtime", "plugin:react-hooks/recommended"]
|
||||
}
|
||||
27
examples/react/offline/.gitignore
vendored
Normal file
27
examples/react/offline/.gitignore
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
package-lock.json
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
6
examples/react/offline/README.md
Normal file
6
examples/react/offline/README.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# Example
|
||||
|
||||
To run this example:
|
||||
|
||||
- `npm install`
|
||||
- `npm run dev`
|
||||
16
examples/react/offline/index.html
Normal file
16
examples/react/offline/index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="shortcut icon" type="image/svg+xml" href="/emblem-light.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
|
||||
<title>TanStack Query React Offline Example App</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
31
examples/react/offline/package.json
Normal file
31
examples/react/offline/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@tanstack/query-example-react-offline",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/query-async-storage-persister": "^5.102.8",
|
||||
"@tanstack/react-location": "^3.7.4",
|
||||
"@tanstack/react-query": "^5.102.8",
|
||||
"@tanstack/react-query-devtools": "^5.102.8",
|
||||
"@tanstack/react-query-persist-client": "^5.102.8",
|
||||
"msw": "^2.6.6",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hot-toast": "^2.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "5.8.3",
|
||||
"vite": "^6.4.1"
|
||||
},
|
||||
"msw": {
|
||||
"workerDirectory": [
|
||||
"public"
|
||||
]
|
||||
}
|
||||
}
|
||||
286
examples/react/offline/public/mockServiceWorker.js
Normal file
286
examples/react/offline/public/mockServiceWorker.js
Normal file
@@ -0,0 +1,286 @@
|
||||
/* eslint-disable */
|
||||
|
||||
/**
|
||||
* Mock Service Worker (2.1.7).
|
||||
* @see https://github.com/mswjs/msw
|
||||
* - Please do NOT modify this file.
|
||||
* - Please do NOT serve this file on production.
|
||||
*/
|
||||
|
||||
const INTEGRITY_CHECKSUM = '223d191a56023cd36aa88c802961b911'
|
||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||
const activeClientIds = new Set()
|
||||
|
||||
self.addEventListener('install', function () {
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
self.addEventListener('activate', function (event) {
|
||||
event.waitUntil(self.clients.claim())
|
||||
})
|
||||
|
||||
self.addEventListener('message', async function (event) {
|
||||
const clientId = event.source.id
|
||||
|
||||
if (!clientId || !self.clients) {
|
||||
return
|
||||
}
|
||||
|
||||
const client = await self.clients.get(clientId)
|
||||
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
switch (event.data) {
|
||||
case 'KEEPALIVE_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'KEEPALIVE_RESPONSE',
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'INTEGRITY_CHECK_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'INTEGRITY_CHECK_RESPONSE',
|
||||
payload: INTEGRITY_CHECKSUM,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'MOCK_ACTIVATE': {
|
||||
activeClientIds.add(clientId)
|
||||
|
||||
sendToClient(client, {
|
||||
type: 'MOCKING_ENABLED',
|
||||
payload: true,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'MOCK_DEACTIVATE': {
|
||||
activeClientIds.delete(clientId)
|
||||
break
|
||||
}
|
||||
|
||||
case 'CLIENT_CLOSED': {
|
||||
activeClientIds.delete(clientId)
|
||||
|
||||
const remainingClients = allClients.filter((client) => {
|
||||
return client.id !== clientId
|
||||
})
|
||||
|
||||
// Unregister itself when there are no more clients
|
||||
if (remainingClients.length === 0) {
|
||||
self.registration.unregister()
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
self.addEventListener('fetch', function (event) {
|
||||
const { request } = event
|
||||
|
||||
// Bypass navigation requests.
|
||||
if (request.mode === 'navigate') {
|
||||
return
|
||||
}
|
||||
|
||||
// Opening the DevTools triggers the "only-if-cached" request
|
||||
// that cannot be handled by the worker. Bypass such requests.
|
||||
if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') {
|
||||
return
|
||||
}
|
||||
|
||||
// Bypass all requests when there are no active clients.
|
||||
// Prevents the self-unregistered worked from handling requests
|
||||
// after it's been deleted (still remains active until the next reload).
|
||||
if (activeClientIds.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Generate unique request ID.
|
||||
const requestId = crypto.randomUUID()
|
||||
event.respondWith(handleRequest(event, requestId))
|
||||
})
|
||||
|
||||
async function handleRequest(event, requestId) {
|
||||
const client = await resolveMainClient(event)
|
||||
const response = await getResponse(event, client, requestId)
|
||||
|
||||
// Send back the response clone for the "response:*" life-cycle events.
|
||||
// Ensure MSW is active and ready to handle the message, otherwise
|
||||
// this message will pend indefinitely.
|
||||
if (client && activeClientIds.has(client.id)) {
|
||||
;(async function () {
|
||||
const responseClone = response.clone()
|
||||
|
||||
sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'RESPONSE',
|
||||
payload: {
|
||||
requestId,
|
||||
isMockedResponse: IS_MOCKED_RESPONSE in response,
|
||||
type: responseClone.type,
|
||||
status: responseClone.status,
|
||||
statusText: responseClone.statusText,
|
||||
body: responseClone.body,
|
||||
headers: Object.fromEntries(responseClone.headers.entries()),
|
||||
},
|
||||
},
|
||||
[responseClone.body],
|
||||
)
|
||||
})()
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
// Resolve the main client for the given event.
|
||||
// Client that issues a request doesn't necessarily equal the client
|
||||
// that registered the worker. It's with the latter the worker should
|
||||
// communicate with during the response resolving phase.
|
||||
async function resolveMainClient(event) {
|
||||
const client = await self.clients.get(event.clientId)
|
||||
|
||||
if (client?.frameType === 'top-level') {
|
||||
return client
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
return allClients
|
||||
.filter((client) => {
|
||||
// Get only those clients that are currently visible.
|
||||
return client.visibilityState === 'visible'
|
||||
})
|
||||
.find((client) => {
|
||||
// Find the client ID that's recorded in the
|
||||
// set of clients that have registered the worker.
|
||||
return activeClientIds.has(client.id)
|
||||
})
|
||||
}
|
||||
|
||||
async function getResponse(event, client, requestId) {
|
||||
const { request } = event
|
||||
|
||||
// Clone the request because it might've been already used
|
||||
// (i.e. its body has been read and sent to the client).
|
||||
const requestClone = request.clone()
|
||||
|
||||
function passthrough() {
|
||||
const headers = Object.fromEntries(requestClone.headers.entries())
|
||||
|
||||
// Remove internal MSW request header so the passthrough request
|
||||
// complies with any potential CORS preflight checks on the server.
|
||||
// Some servers forbid unknown request headers.
|
||||
delete headers['x-msw-intention']
|
||||
|
||||
return fetch(requestClone, { headers })
|
||||
}
|
||||
|
||||
// Bypass mocking when the client is not active.
|
||||
if (!client) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Bypass initial page load requests (i.e. static assets).
|
||||
// The absence of the immediate/parent client in the map of the active clients
|
||||
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
|
||||
// and is not ready to handle requests.
|
||||
if (!activeClientIds.has(client.id)) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Bypass requests with the explicit bypass header.
|
||||
// Such requests can be issued by "ctx.fetch()".
|
||||
const mswIntention = request.headers.get('x-msw-intention')
|
||||
if (['bypass', 'passthrough'].includes(mswIntention)) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Notify the client that a request has been intercepted.
|
||||
const requestBuffer = await request.arrayBuffer()
|
||||
const clientMessage = await sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'REQUEST',
|
||||
payload: {
|
||||
id: requestId,
|
||||
url: request.url,
|
||||
mode: request.mode,
|
||||
method: request.method,
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
destination: request.destination,
|
||||
integrity: request.integrity,
|
||||
redirect: request.redirect,
|
||||
referrer: request.referrer,
|
||||
referrerPolicy: request.referrerPolicy,
|
||||
body: requestBuffer,
|
||||
keepalive: request.keepalive,
|
||||
},
|
||||
},
|
||||
[requestBuffer],
|
||||
)
|
||||
|
||||
switch (clientMessage.type) {
|
||||
case 'MOCK_RESPONSE': {
|
||||
return respondWithMock(clientMessage.data)
|
||||
}
|
||||
|
||||
case 'MOCK_NOT_FOUND': {
|
||||
return passthrough()
|
||||
}
|
||||
}
|
||||
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
function sendToClient(client, message, transferrables = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel()
|
||||
|
||||
channel.port1.onmessage = (event) => {
|
||||
if (event.data && event.data.error) {
|
||||
return reject(event.data.error)
|
||||
}
|
||||
|
||||
resolve(event.data)
|
||||
}
|
||||
|
||||
client.postMessage(
|
||||
message,
|
||||
[channel.port2].concat(transferrables.filter(Boolean)),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function respondWithMock(response) {
|
||||
// Setting response status code to 0 is a no-op.
|
||||
// However, when responding with a "Response.error()", the produced Response
|
||||
// instance will have status code set to 0. Since it's not possible to create
|
||||
// a Response instance with status code 0, handle that use-case separately.
|
||||
if (response.status === 0) {
|
||||
return Response.error()
|
||||
}
|
||||
|
||||
const mockedResponse = new Response(response.body, response)
|
||||
|
||||
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
|
||||
value: true,
|
||||
enumerable: true,
|
||||
})
|
||||
|
||||
return mockedResponse
|
||||
}
|
||||
223
examples/react/offline/src/App.tsx
Normal file
223
examples/react/offline/src/App.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import * as React from 'react'
|
||||
import {
|
||||
MutationCache,
|
||||
QueryClient,
|
||||
onlineManager,
|
||||
useIsRestoring,
|
||||
useQuery,
|
||||
} from '@tanstack/react-query'
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
||||
import { Toaster, toast } from 'react-hot-toast'
|
||||
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'
|
||||
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister'
|
||||
import {
|
||||
Link,
|
||||
Outlet,
|
||||
ReactLocation,
|
||||
Router,
|
||||
useMatch,
|
||||
} from '@tanstack/react-location'
|
||||
|
||||
import * as api from './api'
|
||||
import { movieKeys, useMovie } from './movies'
|
||||
|
||||
const persister = createAsyncStoragePersister({
|
||||
storage: window.localStorage,
|
||||
})
|
||||
|
||||
const location = new ReactLocation()
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
gcTime: 1000 * 60 * 60 * 24, // 24 hours
|
||||
staleTime: 2000,
|
||||
retry: 0,
|
||||
},
|
||||
},
|
||||
// configure global cache callbacks to show toast notifications
|
||||
mutationCache: new MutationCache({
|
||||
onSuccess: (data) => {
|
||||
toast.success(data.message)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
// we need a default mutation function so that paused mutations can resume after a page reload
|
||||
queryClient.setMutationDefaults(movieKeys.all(), {
|
||||
mutationFn: async ({ id, comment }) => {
|
||||
// to avoid clashes with our optimistic update when an offline mutation continues
|
||||
await queryClient.cancelQueries({ queryKey: movieKeys.detail(id) })
|
||||
return api.updateMovie(id, comment)
|
||||
},
|
||||
})
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<PersistQueryClientProvider
|
||||
client={queryClient}
|
||||
persistOptions={{ persister }}
|
||||
onSuccess={() => {
|
||||
// resume mutations after initial restore from localStorage was successful
|
||||
queryClient.resumePausedMutations().then(() => {
|
||||
queryClient.invalidateQueries()
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Movies />
|
||||
<ReactQueryDevtools initialIsOpen />
|
||||
</PersistQueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function Movies() {
|
||||
const isRestoring = useIsRestoring()
|
||||
return (
|
||||
<Router
|
||||
location={location}
|
||||
routes={[
|
||||
{
|
||||
path: '/',
|
||||
element: <List />,
|
||||
},
|
||||
{
|
||||
path: ':movieId',
|
||||
element: <Detail />,
|
||||
errorElement: <MovieError />,
|
||||
loader: ({ params: { movieId } }) =>
|
||||
queryClient.getQueryData(movieKeys.detail(movieId)) ??
|
||||
// do not load if we are offline or hydrating because it returns a promise that is pending until we go online again
|
||||
// we just let the Detail component handle it
|
||||
(onlineManager.isOnline() && !isRestoring
|
||||
? queryClient.query({
|
||||
queryKey: movieKeys.detail(movieId),
|
||||
queryFn: () => api.fetchMovie(movieId),
|
||||
})
|
||||
: undefined),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Outlet />
|
||||
<Toaster />
|
||||
</Router>
|
||||
)
|
||||
}
|
||||
|
||||
function List() {
|
||||
const moviesQuery = useQuery({
|
||||
queryKey: movieKeys.list(),
|
||||
queryFn: api.fetchMovies,
|
||||
})
|
||||
|
||||
if (moviesQuery.isLoading) {
|
||||
return 'Loading...'
|
||||
}
|
||||
|
||||
if (moviesQuery.data) {
|
||||
return (
|
||||
<div>
|
||||
<h1>Movies</h1>
|
||||
<p>
|
||||
Try to mock offline behaviour with the button in the devtools. You can
|
||||
navigate around as long as there is already data in the cache. You'll
|
||||
get a refetch as soon as you go online again.
|
||||
</p>
|
||||
<ul>
|
||||
{moviesQuery.data.movies.map((movie) => (
|
||||
<li key={movie.id}>
|
||||
<Link to={`./${movie.id}`} preload>
|
||||
{movie.title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div>
|
||||
Updated at: {new Date(moviesQuery.data.ts).toLocaleTimeString()}
|
||||
</div>
|
||||
<div>{moviesQuery.isFetching && 'fetching...'}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// query will be in 'idle' fetchStatus while restoring from localStorage
|
||||
return null
|
||||
}
|
||||
|
||||
function MovieError() {
|
||||
const { error } = useMatch()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Link to="..">Back</Link>
|
||||
<h1>Couldn't load movie!</h1>
|
||||
<div>{error.message}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Detail() {
|
||||
const {
|
||||
params: { movieId },
|
||||
} = useMatch()
|
||||
const { comment, setComment, updateMovie, movieQuery } = useMovie(movieId)
|
||||
|
||||
if (movieQuery.isLoading) {
|
||||
return 'Loading...'
|
||||
}
|
||||
|
||||
function submitForm(event: any) {
|
||||
event.preventDefault()
|
||||
|
||||
updateMovie.mutate({
|
||||
id: movieId,
|
||||
comment,
|
||||
})
|
||||
}
|
||||
|
||||
if (movieQuery.data) {
|
||||
return (
|
||||
<form onSubmit={submitForm}>
|
||||
<Link to="..">Back</Link>
|
||||
<h1>Movie: {movieQuery.data.movie.title}</h1>
|
||||
<p>
|
||||
Try to mock offline behaviour with the button in the devtools, then
|
||||
update the comment. The optimistic update will succeed, but the actual
|
||||
mutation will be paused and resumed once you go online again.
|
||||
</p>
|
||||
<p>
|
||||
You can also reload the page, which will make the persisted mutation
|
||||
resume, as you will be online again when you "come back".
|
||||
</p>
|
||||
<p>
|
||||
<label>
|
||||
Comment: <br />
|
||||
<textarea
|
||||
name="comment"
|
||||
value={comment}
|
||||
onChange={(event) => setComment(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</p>
|
||||
<button type="submit">Submit</button>
|
||||
<div>
|
||||
Updated at: {new Date(movieQuery.data.ts).toLocaleTimeString()}
|
||||
</div>
|
||||
<div>{movieQuery.isFetching && 'fetching...'}</div>
|
||||
<div>
|
||||
{updateMovie.isPaused
|
||||
? 'mutation paused - offline'
|
||||
: updateMovie.isPending && 'updating...'}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
if (movieQuery.isPaused) {
|
||||
return "We're offline and have no data to show :("
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
84
examples/react/offline/src/api.ts
Normal file
84
examples/react/offline/src/api.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { HttpResponse, delay, http, passthrough } from 'msw'
|
||||
import { setupWorker } from 'msw/browser'
|
||||
|
||||
const movies = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Guardians of the Galaxy',
|
||||
comment: '',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Wall-E',
|
||||
comment: '',
|
||||
},
|
||||
]
|
||||
|
||||
export const fetchMovie = async (
|
||||
id: string,
|
||||
): Promise<{
|
||||
ts: number
|
||||
movie: { comment: string; id: string; title: string }
|
||||
}> => {
|
||||
const response = await fetch(`/movies/${id}`)
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
export const fetchMovies = async (): Promise<{
|
||||
ts: number
|
||||
movies: typeof movies
|
||||
}> => {
|
||||
const response = await fetch('/movies')
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
export const updateMovie = async (id: string, comment: string) => {
|
||||
const response = await fetch(`/movies/${id}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ comment }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
export const worker = setupWorker(
|
||||
http.get('/movies', async () => {
|
||||
await delay(500)
|
||||
return HttpResponse.json({
|
||||
ts: Date.now(),
|
||||
movies: movies.map(({ id, title }) => ({ id, title })),
|
||||
})
|
||||
}),
|
||||
|
||||
http.get('/movies/:id', async ({ params }) => {
|
||||
const { id } = params
|
||||
|
||||
const movie = movies.find((movie) => movie.id === id)
|
||||
|
||||
if (!movie) {
|
||||
return new HttpResponse(`Movie with id ${id} not found`, {
|
||||
status: 404,
|
||||
})
|
||||
}
|
||||
|
||||
await delay(500)
|
||||
return HttpResponse.json({ ts: Date.now(), movie })
|
||||
}),
|
||||
|
||||
http.post('/movies/:id', async ({ request, params }) => {
|
||||
const { id } = params
|
||||
const body = await request.json()
|
||||
const { comment } = body
|
||||
|
||||
movies.forEach((movie) => {
|
||||
if (movie.id === id) {
|
||||
movie.comment = comment.toUpperCase()
|
||||
}
|
||||
})
|
||||
|
||||
await delay(500)
|
||||
return HttpResponse.json({ message: `Successfully updated movie ${id}` })
|
||||
}),
|
||||
http.get('*.js', () => passthrough()),
|
||||
http.get('*.svg', () => passthrough()),
|
||||
)
|
||||
14
examples/react/offline/src/index.tsx
Normal file
14
examples/react/offline/src/index.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import { worker } from './api'
|
||||
|
||||
worker.start()
|
||||
|
||||
const rootElement = document.getElementById('root')
|
||||
if (!rootElement) throw new Error('Missing #root element')
|
||||
ReactDOM.createRoot(rootElement).render(
|
||||
<div style={{ padding: '16px' }}>
|
||||
<App />
|
||||
</div>,
|
||||
)
|
||||
55
examples/react/offline/src/movies.ts
Normal file
55
examples/react/offline/src/movies.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import * as React from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import * as api from './api'
|
||||
|
||||
export const movieKeys = {
|
||||
all: () => ['movies'],
|
||||
list: () => [...movieKeys.all(), 'list'],
|
||||
details: () => [...movieKeys.all(), 'detail'],
|
||||
detail: (id: string) => [...movieKeys.details(), id],
|
||||
}
|
||||
|
||||
export const useMovie = (movieId: string) => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const movieQuery = useQuery({
|
||||
queryKey: movieKeys.detail(movieId),
|
||||
queryFn: () => api.fetchMovie(movieId),
|
||||
})
|
||||
|
||||
const [comment, setComment] = React.useState<string | undefined>()
|
||||
|
||||
const updateMovie = useMutation({
|
||||
mutationKey: movieKeys.detail(movieId),
|
||||
onMutate: async () => {
|
||||
await queryClient.cancelQueries({ queryKey: movieKeys.detail(movieId) })
|
||||
const previousData = queryClient.getQueryData(movieKeys.detail(movieId))
|
||||
|
||||
// remove local state so that server state is taken instead
|
||||
setComment(undefined)
|
||||
|
||||
queryClient.setQueryData(movieKeys.detail(movieId), {
|
||||
...previousData,
|
||||
movie: {
|
||||
...previousData.movie,
|
||||
comment,
|
||||
},
|
||||
})
|
||||
|
||||
return { previousData }
|
||||
},
|
||||
onError: (_, __, context) => {
|
||||
queryClient.setQueryData(movieKeys.detail(movieId), context.previousData)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: movieKeys.detail(movieId) })
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
comment: comment ?? movieQuery.data?.movie.comment,
|
||||
setComment,
|
||||
updateMovie,
|
||||
movieQuery,
|
||||
}
|
||||
}
|
||||
24
examples/react/offline/tsconfig.json
Normal file
24
examples/react/offline/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"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", "eslint.config.js"]
|
||||
}
|
||||
6
examples/react/offline/vite.config.ts
Normal file
6
examples/react/offline/vite.config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
Reference in New Issue
Block a user