diff --git a/examples/solid/basic-typescript/package.json b/examples/solid/basic-typescript/package.json index 0e265cf44c3..f45f1cf1666 100644 --- a/examples/solid/basic-typescript/package.json +++ b/examples/solid/basic-typescript/package.json @@ -10,10 +10,10 @@ }, "license": "MIT", "dependencies": { - "@tanstack/solid-query": "^4.3.9", "solid-js": "^1.5.1" }, "devDependencies": { + "@tanstack/solid-query": "^4.3.9", "typescript": "^4.8.2", "vite": "^3.0.9", "vite-plugin-solid": "^2.3.9" diff --git a/examples/solid/offline/.eslintrc b/examples/solid/offline/.eslintrc new file mode 100644 index 00000000000..86b22fec59b --- /dev/null +++ b/examples/solid/offline/.eslintrc @@ -0,0 +1,10 @@ +{ + "parserOptions": { + "project": "./tsconfig.json", + "sourceType": "module" + }, + "rules": { + "react/react-in-jsx-scope": "off", + "jsx-a11y/anchor-is-valid": "off" + } +} diff --git a/examples/solid/offline/.gitignore b/examples/solid/offline/.gitignore new file mode 100644 index 00000000000..001e3f924bb --- /dev/null +++ b/examples/solid/offline/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +.yalc +yalc.lock \ No newline at end of file diff --git a/examples/solid/offline/README.md b/examples/solid/offline/README.md new file mode 100644 index 00000000000..310f37f62fd --- /dev/null +++ b/examples/solid/offline/README.md @@ -0,0 +1,6 @@ +# Example + +To run this example: + +- `npm install` +- `npm run start` diff --git a/examples/solid/offline/index.html b/examples/solid/offline/index.html new file mode 100644 index 00000000000..48c59fc1242 --- /dev/null +++ b/examples/solid/offline/index.html @@ -0,0 +1,16 @@ + + + + + + + + Solid App + + + +
+ + + + diff --git a/examples/solid/offline/mockServiceWorker.js b/examples/solid/offline/mockServiceWorker.js new file mode 100644 index 00000000000..0966a9df4fb --- /dev/null +++ b/examples/solid/offline/mockServiceWorker.js @@ -0,0 +1,338 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker (0.39.2). + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + * - Please do NOT serve this file on production. + */ + +const INTEGRITY_CHECKSUM = '02f4ad4a2797f85668baf196e553d929' +const bypassHeaderName = 'x-msw-bypass' +const activeClientIds = new Set() + +self.addEventListener('install', function () { + return self.skipWaiting() +}) + +self.addEventListener('activate', async function (event) { + return 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() + + 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 + } + } +}) + +// 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() + + 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 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 clonedResponse = response.clone() + sendToClient(client, { + type: 'RESPONSE', + payload: { + requestId, + type: clonedResponse.type, + ok: clonedResponse.ok, + status: clonedResponse.status, + statusText: clonedResponse.statusText, + body: + clonedResponse.body === null ? null : await clonedResponse.text(), + headers: serializeHeaders(clonedResponse.headers), + redirected: clonedResponse.redirected, + }, + }) + })() + } + + return response +} + +async function getResponse(event, client, requestId) { + const { request } = event + const requestClone = request.clone() + const getOriginalResponse = () => fetch(requestClone) + + // Bypass mocking when the request client is not active. + if (!client) { + return getOriginalResponse() + } + + // 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 await getOriginalResponse() + } + + // Bypass requests with the explicit bypass header + if (requestClone.headers.get(bypassHeaderName) === 'true') { + const cleanRequestHeaders = serializeHeaders(requestClone.headers) + + // Remove the bypass header to comply with the CORS preflight check. + delete cleanRequestHeaders[bypassHeaderName] + + const originalRequest = new Request(requestClone, { + headers: new Headers(cleanRequestHeaders), + }) + + return fetch(originalRequest) + } + + // Send the request to the client-side MSW. + const reqHeaders = serializeHeaders(request.headers) + const body = await request.text() + + const clientMessage = await sendToClient(client, { + type: 'REQUEST', + payload: { + id: requestId, + url: request.url, + method: request.method, + headers: reqHeaders, + cache: request.cache, + mode: request.mode, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body, + bodyUsed: request.bodyUsed, + keepalive: request.keepalive, + }, + }) + + switch (clientMessage.type) { + case 'MOCK_SUCCESS': { + return delayPromise( + () => respondWithMock(clientMessage), + clientMessage.payload.delay, + ) + } + + case 'MOCK_NOT_FOUND': { + return getOriginalResponse() + } + + case 'NETWORK_ERROR': { + const { name, message } = clientMessage.payload + const networkError = new Error(message) + networkError.name = name + + // Rejecting a request Promise emulates a network error. + throw networkError + } + + case 'INTERNAL_ERROR': { + const parsedBody = JSON.parse(clientMessage.payload.body) + + console.error( + `\ +[MSW] Uncaught exception in the request handler for "%s %s": + +${parsedBody.location} + +This exception has been gracefully handled as a 500 response, however, it's strongly recommended to resolve this error, as it indicates a mistake in your code. If you wish to mock an error response, please see this guide: https://mswjs.io/docs/recipes/mocking-error-responses\ +`, + request.method, + request.url, + ) + + return respondWithMock(clientMessage) + } + } + + return getOriginalResponse() +} + +self.addEventListener('fetch', function (event) { + const { request } = event + const accept = request.headers.get('accept') || '' + + // Bypass server-sent events. + if (accept.includes('text/event-stream')) { + return + } + + // 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 + } + + const requestId = uuidv4() + + return event.respondWith( + handleRequest(event, requestId).catch((error) => { + if (error.name === 'NetworkError') { + console.warn( + '[MSW] Successfully emulated a network error for the "%s %s" request.', + request.method, + request.url, + ) + return + } + + // At this point, any exception indicates an issue with the original request/response. + console.error( + `\ +[MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`, + request.method, + request.url, + `${error.name}: ${error.message}`, + ) + }), + ) +}) + +function serializeHeaders(headers) { + const reqHeaders = {} + headers.forEach((value, name) => { + reqHeaders[name] = reqHeaders[name] + ? [].concat(reqHeaders[name]).concat(value) + : value + }) + return reqHeaders +} + +function sendToClient(client, message) { + 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(JSON.stringify(message), [channel.port2]) + }) +} + +function delayPromise(cb, duration) { + return new Promise((resolve) => { + setTimeout(() => resolve(cb()), duration) + }) +} + +function respondWithMock(clientMessage) { + return new Response(clientMessage.payload.body, { + ...clientMessage.payload, + headers: clientMessage.payload.headers, + }) +} + +function uuidv4() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { + const r = (Math.random() * 16) | 0 + const v = c == 'x' ? r : (r & 0x3) | 0x8 + return v.toString(16) + }) +} diff --git a/examples/solid/offline/package.json b/examples/solid/offline/package.json new file mode 100644 index 00000000000..a501d06881a --- /dev/null +++ b/examples/solid/offline/package.json @@ -0,0 +1,34 @@ +{ + "name": "@tanstack/query-example-solid-offline", + "version": "0.0.0", + "description": "", + "scripts": { + "start": "vite", + "dev": "vite", + "build": "vite build", + "serve": "vite preview" + }, + "license": "MIT", + "dependencies": { + "solid-js": "1.5.4" + }, + "devDependencies": { + "@solidjs/router": "^0.5.0", + "@tanstack/query-async-storage-persister": "workspace:*", + "@tanstack/solid-query": "workspace:*", + "@tanstack/solid-query-persist-client": "workspace:*", + "@types/lodash": "^4.14.186", + "deepdash": "^5.3.9", + "idb-keyval": "^6.2.0", + "ky": "^0.30.0", + "lodash": "^4.17.21", + "msw": "^0.39.2", + "solid-toast": "^0.3.5", + "typescript": "^4.8.2", + "vite": "^3.0.9", + "vite-plugin-solid": "^2.3.9" + }, + "msw": { + "workerDirectory": "" + } +} diff --git a/examples/solid/offline/src/App.tsx b/examples/solid/offline/src/App.tsx new file mode 100644 index 00000000000..2946887a44d --- /dev/null +++ b/examples/solid/offline/src/App.tsx @@ -0,0 +1,319 @@ +/* @refresh reload */ + +import { + createQuery, + QueryClient, + //QueryClientProvider, + MutationCache, + onlineManager, + useIsRestoring, + //useQueryClient, +} from '@tanstack/solid-query' + +import { + PersistQueryClientProvider, +} from '@tanstack/solid-query-persist-client' + +import { createIndexedDBPersister } from './persister' + +import { Component, createSignal, For, Match, Setter, Switch } from 'solid-js' + +// TODO @tanstack/solid-query-devtools +//import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; + +//import toast, { Toaster } from "react-hot-toast"; +import toast, { Toaster } from 'solid-toast'; + +/* +import { + Link, + Outlet, + ReactLocation, + Router, + useMatch, +} from "@tanstack/react-location"; +*/ + +// TODO: A vs Navigate? +// https://github.com/solidjs/solid-router#the-navigate-component +// Solid Router provides a Navigate component that works similarly to A, +// but it will *immediately* navigate to the provided path +// as soon as the component is rendered + +import { Routes, Route, A as Link, useParams } from "@solidjs/router"; + +import * as api from "./api"; +import { movieKeys, useMovie } from "./movies"; + +/* TODO? + +import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister"; +const persister = createSyncStoragePersister({ + storage: window.localStorage, +}); + +import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister"; +const persister = createAsyncStoragePersister() + +*/ + +const persister = createIndexedDBPersister() + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + cacheTime: 1000 * 60 * 60 * 24, // 24 hours + staleTime: 2000, + retry: 0, + refetchOnWindowFocus: false, + refetchOnMount: false, + refetchOnReconnect: false, + //retryOnMount: false, + }, + }, + // configure global cache callbacks to show toast notifications + mutationCache: new MutationCache({ + onSuccess: (data: any) => { + toast.success(data.message); + }, + onError: (error: any) => { + 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(movieKeys.detail(id)); + return api.updateMovie(id, comment); + }, +}); + +export function App() { + return ( + { + // resume mutations after initial restore from localStorage was successful + await queryClient.resumePausedMutations(); + // no. this would refetch queries + //await queryClient.invalidateQueries(); + }} + > + + { + // + } + + ); +} + +function Movies() { + const isRestoring = useIsRestoring(); + return ( +
+ + + + //data={fetchMovie} + /> + This site was made with Solid
} + /> + + { + // + } + + + ); +} + +function List() { + console.log('List: createQuery', movieKeys.list(), api.fetchMovies) + const moviesQuery = createQuery( + () => movieKeys.list(), + api.fetchMovies + ); + + return ( +
+

Movies

+
+ + Loading... + We're offline and have no data to show :( + + Error: {(moviesQuery.error as Error).message} + + + <> + {/* TODO solid devtools +

+ 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. +

+ */} +
    + + {(movie) => ( +
  • + { + // TODO preload + // + } + + {movie.title} + +
  • + )} +
    +
+
+ Updated at: {new Date(moviesQuery.data?.ts || 0).toLocaleTimeString()} +
+
{moviesQuery.isFetching ? 'fetching...' : ' '}
+ +
+ { + // query will be in 'idle' fetchStatus while restoring from localStorage + } + restoring... +
+
+
+ ) +} + +/* TODO +function MovieError() { + const { error } = useMatch(); + + return ( +
+ Back +

Couldn't load movie!

+
{error.message}
+
+ ); +} +*/ + +function Detail() { + + const props = useParams(); + + /* + const fetchMovie = ({ props: { movieId } }: { props: any }) => { + console.log(`fetchMovie: arguments`, arguments) + return 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.fetchQuery(movieKeys.detail(movieId), () => + api.fetchMovie(movieId) + ) + : undefined) + } + */ + + const { comment, setComment, updateMovie, movieQuery } = useMovie(props.movieId); + + function submitForm(event: any) { + event.preventDefault(); + + updateMovie.mutate({ + id: props.movieId, + comment: comment(), + } as any); + } + + return ( +
+ + + No movieId + + + Loading... + + + We're offline and have no data to show :( + + + Error: {(movieQuery.error as Error).message} + + +
+ Back +

Movie: {movieQuery.data?.movie.title}

+ {/* +

+ 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. +

+ */} +

+ You can also reload the page, which will make the persisted mutation + resume, as you will be online again when you "come back". +

+

+