diff --git a/packages/web/package.json b/packages/web/package.json index f25d4c4..db17027 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -16,8 +16,6 @@ "watch": "concurrently \"vavite serve\" \"pnpm generate --watch\"" }, "devDependencies": { - "@0no-co/graphqlsp": "^1.3.0", - "@apollo/client": "^3.8.9", "@atlasbot/configs": "^10.5.15", "@fastify/early-hints": "^1.0.1", "@fastify/http-proxy": "^9.3.0", @@ -32,6 +30,8 @@ "@types/node": "^20.10.6", "@types/react": "^18.2.47", "@types/react-dom": "^18.2.18", + "@urql/devtools": "^2.0.3", + "@urql/exchange-graphcache": "^6.4.1", "autoprefixer": "^10.4.16", "clsx": "^2.1.0", "concurrently": "^8.2.2", @@ -61,6 +61,7 @@ "tailwindcss": "^3.4.1", "tsup": "^8.0.1", "typescript": "^5.3.3", + "urql": "^4.0.6", "vavite": "^4.0.1", "vike": "^0.4.156", "vite": "^5.0.11", diff --git a/packages/web/src/components/breadcrumbs.tsx b/packages/web/src/components/breadcrumbs.tsx index b446e7d..e61b71d 100644 --- a/packages/web/src/components/breadcrumbs.tsx +++ b/packages/web/src/components/breadcrumbs.tsx @@ -1,5 +1,5 @@ import clsx from 'clsx'; -import React, { forwardRef } from 'react'; +import { forwardRef } from 'react'; import { FiArrowLeft } from 'react-icons/fi'; export interface BreadcrumbsProps { diff --git a/packages/web/src/components/button.tsx b/packages/web/src/components/button.tsx index 1a3756a..ec74a6c 100644 --- a/packages/web/src/components/button.tsx +++ b/packages/web/src/components/button.tsx @@ -1,7 +1,7 @@ /* eslint-disable react/button-has-type */ import clsx from 'clsx'; import type { FC, HTMLAttributes } from 'react'; -import React, { forwardRef } from 'react'; +import { forwardRef } from 'react'; import { Spinner } from './spinner'; export interface ButtonProps extends Omit, 'prefix' | 'style'> { diff --git a/packages/web/src/components/container.tsx b/packages/web/src/components/container.tsx index fac0343..7fad029 100644 --- a/packages/web/src/components/container.tsx +++ b/packages/web/src/components/container.tsx @@ -1,6 +1,5 @@ import clsx from 'clsx'; import type { FC, ReactNode } from 'react'; -import React from 'react'; export interface ContainerProps { centerX?: boolean; diff --git a/packages/web/src/components/header/header.tsx b/packages/web/src/components/header/header.tsx index 598d236..a38f7e6 100644 --- a/packages/web/src/components/header/header.tsx +++ b/packages/web/src/components/header/header.tsx @@ -13,7 +13,7 @@ import { Link } from '../link'; import { useToasts } from '../toast'; import { HeaderUser } from './header-user'; import { graphql } from '../../@generated'; -import { useMutation } from '@apollo/client'; +import { useMutation } from 'urql'; const ResendVerificationEmail = graphql(` mutation ResendVerificationEmail($data: ResendVerificationEmailDto) { @@ -40,7 +40,7 @@ export const Header = memo(() => { setShowEmailInput(false); }); - const [resendMutation] = useMutation(ResendVerificationEmail); + const [, resendMutation] = useMutation(ResendVerificationEmail); const [resendVerification, sendingVerification] = useAsync(async () => { if (resent || !user.data) return; if (!user.data.email && !email) { @@ -51,9 +51,7 @@ export const Header = memo(() => { const payload = !user.data.email && email ? { email } : null; try { await resendMutation({ - variables: { - data: payload, - }, + data: payload, }); setShowEmailInput(false); diff --git a/packages/web/src/components/link.tsx b/packages/web/src/components/link.tsx index 5afb001..c34665b 100644 --- a/packages/web/src/components/link.tsx +++ b/packages/web/src/components/link.tsx @@ -1,4 +1,4 @@ -import { forwardRef, Fragment, type HTMLAttributes } from 'react'; +import { forwardRef, type HTMLAttributes } from 'react'; export interface LinkProps extends HTMLAttributes { href: string; diff --git a/packages/web/src/components/spinner.tsx b/packages/web/src/components/spinner.tsx index f694727..458bcaf 100644 --- a/packages/web/src/components/spinner.tsx +++ b/packages/web/src/components/spinner.tsx @@ -1,6 +1,5 @@ import clsx from 'clsx'; import type { FC, HTMLAttributes } from 'react'; -import React from 'react'; export interface SpinnerProps extends HTMLAttributes { size?: 'small' | 'medium' | 'large'; diff --git a/packages/web/src/components/toast/context.ts b/packages/web/src/components/toast/context.ts index 6d4f246..c6718c2 100644 --- a/packages/web/src/components/toast/context.ts +++ b/packages/web/src/components/toast/context.ts @@ -1,5 +1,5 @@ -import React from "react"; -import { ToastProps } from "./toast"; +import React from 'react'; +import { ToastProps } from './toast'; export type ToastContextData = null | ((toast: ToastProps) => void); export const ToastContext = React.createContext(null); diff --git a/packages/web/src/components/toast/toast-provider.tsx b/packages/web/src/components/toast/toast-provider.tsx index 44b534c..f904fa2 100644 --- a/packages/web/src/components/toast/toast-provider.tsx +++ b/packages/web/src/components/toast/toast-provider.tsx @@ -1,9 +1,9 @@ import { nanoid } from 'nanoid'; import type { FC, ReactNode } from 'react'; -import React, { Fragment, useCallback, useState } from 'react'; +import { useCallback, useState } from 'react'; import { ToastContext } from './context'; import type { ToastProps } from './toast'; -import { Toast, TRANSITION_DURATION } from './toast'; +import { TRANSITION_DURATION, Toast } from './toast'; // spread operators on arrays are to fix this // https://stackoverflow.com/questions/56266575/why-is-usestate-not-triggering-re-render diff --git a/packages/web/src/components/toast/toast.tsx b/packages/web/src/components/toast/toast.tsx index ff914d1..b89149f 100644 --- a/packages/web/src/components/toast/toast.tsx +++ b/packages/web/src/components/toast/toast.tsx @@ -1,6 +1,6 @@ import clsx from 'clsx'; import type { FC } from 'react'; -import React, { useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; export interface ToastProps { text: string; diff --git a/packages/web/src/containers/file-list/file-list.tsx b/packages/web/src/containers/file-list/file-list.tsx index 447a82c..663970a 100644 --- a/packages/web/src/containers/file-list/file-list.tsx +++ b/packages/web/src/containers/file-list/file-list.tsx @@ -1,6 +1,6 @@ -import { useQuery } from '@apollo/client'; import type { FC } from 'react'; import { Fragment } from 'react'; +import { useQuery } from 'urql'; import { graphql } from '../../@generated'; import { Breadcrumbs } from '../../components/breadcrumbs'; import { Card } from '../../components/card'; @@ -51,9 +51,10 @@ const GetPastesQuery = graphql(` export const FileList: FC = () => { const [filter, setFilter] = useQueryState('filter', 'files'); - const files = useQuery(GetFilesQuery, { skip: filter !== 'files' }); - const pastes = useQuery(GetPastesQuery, { skip: filter !== 'pastes' }); + const [files, filesFetchMore] = useQuery({ query: GetFilesQuery, pause: filter !== 'files' }); + const [pastes, pastesFetchMore] = useQuery({ query: GetPastesQuery, pause: filter !== 'pastes' }); const source = filter === 'files' ? files : pastes; + const fetchMore = filter === 'files' ? filesFetchMore : pastesFetchMore; if (source.error) { return ; } @@ -102,7 +103,7 @@ export const FileList: FC = () => { {pastes.data?.user.pastes.edges.map(({ node }) => )} )} - {!source.loading && !hasContent && ( + {!source.fetching && !hasContent && ( You haven't uploaded anything yet. Once you upload something, it will appear here. @@ -113,7 +114,7 @@ export const FileList: FC = () => { className="w-full bg-dark-200 px-2 py-2 text-gray-500 hover:bg-dark-300 transition" type="button" onClick={() => { - source.fetchMore({ + fetchMore({ variables: { after: currentPageInfo.pageInfo.endCursor, }, diff --git a/packages/web/src/hooks/useConfig.tsx b/packages/web/src/hooks/useConfig.tsx index 0925718..4b6e2c1 100644 --- a/packages/web/src/hooks/useConfig.tsx +++ b/packages/web/src/hooks/useConfig.tsx @@ -1,4 +1,4 @@ -import { useQuery } from '@apollo/client'; +import { CombinedError, useQuery } from 'urql'; import { graphql } from '../@generated'; const ConfigQuery = graphql(` @@ -24,9 +24,9 @@ const ConfigQuery = graphql(` `); export const useConfig = () => { - const config = useQuery(ConfigQuery); + const [config] = useQuery({ query: ConfigQuery }); return { - ...config, + error: config.error as CombinedError | undefined, data: config.data?.config, }; }; diff --git a/packages/web/src/hooks/useUser.tsx b/packages/web/src/hooks/useUser.tsx index 5a47d0a..d24eb63 100644 --- a/packages/web/src/hooks/useUser.tsx +++ b/packages/web/src/hooks/useUser.tsx @@ -1,5 +1,5 @@ -import { TypedDocumentNode, useMutation, useQuery } from '@apollo/client'; import { useEffect, useState } from 'react'; +import { CombinedError, TypedDocumentNode, useMutation, useQuery } from 'urql'; import { graphql } from '../@generated'; import type { GetUserQuery, LoginMutationVariables, RegularUserFragment } from '../@generated/graphql'; import { navigate, reload } from '../helpers/routing'; @@ -38,10 +38,10 @@ const LogoutMutation = graphql(` export const useLoginUser = () => { const [otp, setOtp] = useState(false); - const [loginMutation] = useMutation(LoginMutation); + const [, loginMutation] = useMutation(LoginMutation); const [login] = useAsync(async (variables: LoginMutationVariables) => { try { - await loginMutation({ variables }); + await loginMutation(variables); navigate('/dashboard'); } catch (error: any) { if (error.message.toLowerCase().includes('otp')) { @@ -59,7 +59,7 @@ export const useLoginUser = () => { }; export const useLogoutUser = () => { - const [logoutMutation] = useMutation(LogoutMutation); + const [, logoutMutation] = useMutation(LogoutMutation); const [logout] = useAsync(async () => { await logoutMutation({}); reload(); @@ -69,27 +69,27 @@ export const useLogoutUser = () => { }; export const useUserRedirect = ( - query: { data: { user: RegularUserFragment } | null | undefined; loading: boolean; called: boolean }, + query: { data?: { user: RegularUserFragment } | null | undefined; fetching: boolean }, redirect: boolean | undefined, ) => { useEffect(() => { - if (!query.data && !query.loading && query.called && redirect) { + if (!query.data && !query.fetching && redirect) { navigate(`/login?to=${window.location.href}`); } - }, [redirect, query.data, query.loading, query.called]); + }, [redirect, query.data, query.fetching]); }; export const useUser = >(redirect?: boolean, query?: T) => { const { login, otpRequired } = useLoginUser(); const { logout } = useLogoutUser(); - const { data, loading, called, error } = useQuery((query || UserQuery) as T); + const [{ data, fetching, error }] = useQuery({ query: (query || UserQuery) as T }); - useUserRedirect({ data, loading, called }, redirect); + useUserRedirect({ data, fetching }, redirect); return { data: data?.user as RegularUserFragment | null | undefined, - loading: loading, - error: error, + fetching: fetching, + error: error as CombinedError | undefined, otpRequired: otpRequired, login: login, logout: logout, diff --git a/packages/web/src/pages/dashboard/mfa/+Page.tsx b/packages/web/src/pages/dashboard/mfa/+Page.tsx index 86b9ff8..49335c2 100644 --- a/packages/web/src/pages/dashboard/mfa/+Page.tsx +++ b/packages/web/src/pages/dashboard/mfa/+Page.tsx @@ -1,4 +1,4 @@ -import { useMutation, useQuery } from '@apollo/client'; +import { useMutation, useQuery } from 'urql'; import clsx from 'clsx'; import { QRCodeSVG } from 'qrcode.react'; import { FC, Fragment, useCallback, useMemo } from 'react'; @@ -32,10 +32,10 @@ const ConfirmOTP = graphql(` `); export const Page: FC = () => { - const result = useQuery(GenerateOtp); + const [result] = useQuery({ query: GenerateOtp }); const createToast = useToasts(); const [currentStep, setCurrentStep] = useQueryState('step', 0, Number); - const [confirmOtp] = useMutation(ConfirmOTP); + const [, confirmOtp] = useMutation(ConfirmOTP); const copyable = useMemo(() => { if (!result.data) return; @@ -64,7 +64,7 @@ export const Page: FC = () => { const [confirm, confirming] = useAsync(async (otpCode: string) => { try { - await confirmOtp({ variables: { otpCode } }); + await confirmOtp({ otpCode }); createToast({ text: 'Successfully enabled 2FA!' }); navigate('/dashboard', { overwriteLastHistoryEntry: true }); } catch (error: any) { @@ -77,7 +77,7 @@ export const Page: FC = () => { } }); - if (result.loading) return ; + if (result.fetching) return ; if (!result.data) return ; return ( diff --git a/packages/web/src/pages/dashboard/preferences/+Page.tsx b/packages/web/src/pages/dashboard/preferences/+Page.tsx index a30ac65..22bbe46 100644 --- a/packages/web/src/pages/dashboard/preferences/+Page.tsx +++ b/packages/web/src/pages/dashboard/preferences/+Page.tsx @@ -1,7 +1,6 @@ -import { useMutation, useQuery } from '@apollo/client'; import { FC, Fragment } from 'react'; +import { useMutation, useQuery } from 'urql'; import { graphql } from '../../../@generated'; -import { GetUserDocument } from '../../../@generated/graphql'; import { Breadcrumbs } from '../../../components/breadcrumbs'; import { Button } from '../../../components/button'; import { Container } from '../../../components/container'; @@ -39,22 +38,20 @@ const UserQueryWithToken = graphql(` `); export const Page: FC = () => { - const user = useQuery(UserQueryWithToken); + const [user] = useQuery({ query: UserQueryWithToken }); const { logout } = useLogoutUser(); - const [refreshMutation] = useMutation(RefreshToken); + const [, refreshMutation] = useMutation(RefreshToken); const [refresh, refreshing] = useAsync(async () => { // eslint-disable-next-line no-alert const confirmation = confirm('Are you sure? This will invalidate all existing configs and sessions and will sign you out of the dashboard.') // prettier-ignore if (!confirmation) return; - await refreshMutation(); + await refreshMutation({}); await logout(); }); useUserRedirect(user, true); - const [disableOTP, disableOTPMut] = useMutation(DisableOtp, { - refetchQueries: [{ query: GetUserDocument }], - }); + const [disableOTPMut, disableOTP] = useMutation(DisableOtp); return ( @@ -125,11 +122,9 @@ export const Page: FC = () => {
{user.data && user.data.user.otpEnabled && ( { - disableOTP({ - variables: { otpCode }, - }); + disableOTP({ otpCode }); }} /> )} diff --git a/packages/web/src/pages/file/@fileId/+Page.tsx b/packages/web/src/pages/file/@fileId/+Page.tsx index 8eec40f..0a30e57 100644 --- a/packages/web/src/pages/file/@fileId/+Page.tsx +++ b/packages/web/src/pages/file/@fileId/+Page.tsx @@ -1,9 +1,9 @@ -import { useMutation, useQuery } from '@apollo/client'; import clsx from 'clsx'; import copyToClipboard from 'copy-to-clipboard'; import type { FC, ReactNode } from 'react'; import { Fragment, useState } from 'react'; import { FiDownload, FiShare, FiTrash } from 'react-icons/fi'; +import { useMutation, useQuery } from 'urql'; import { graphql } from '../../../@generated'; import { Container } from '../../../components/container'; import { Embed } from '../../../components/embed/embed'; @@ -72,14 +72,15 @@ export const Page: FC = ({ routeParams }) => { const [deleteKey] = useQueryState('deleteKey'); const [confirm, setConfirm] = useState(false); const createToast = useToasts(); - const file = useQuery(GetFile, { - skip: !fileId, + const [file] = useQuery({ + query: GetFile, + pause: !fileId, variables: { fileId: fileId as string, }, }); - const [deleteMutation] = useMutation(DeleteFile); + const [, deleteMutation] = useMutation(DeleteFile); const copyLink = () => { copyToClipboard(file.data?.file.urls.view ?? window.location.href); createToast({ @@ -100,10 +101,8 @@ export const Page: FC = ({ routeParams }) => { } await deleteMutation({ - variables: { - fileId: file.data.file.id, - deleteKey: deleteKey, - }, + fileId: file.data.file.id, + deleteKey: deleteKey, }); createToast({ text: `Deleted "${file.data.file.displayName}"` }); diff --git a/packages/web/src/pages/invite/@inviteToken/+Page.tsx b/packages/web/src/pages/invite/@inviteToken/+Page.tsx index 2ecde63..a501a53 100644 --- a/packages/web/src/pages/invite/@inviteToken/+Page.tsx +++ b/packages/web/src/pages/invite/@inviteToken/+Page.tsx @@ -1,4 +1,3 @@ -import { useMutation, useQuery } from '@apollo/client'; import { FC, useEffect } from 'react'; import { graphql } from '../../../@generated'; import { Container } from '../../../components/container'; @@ -14,6 +13,7 @@ import { navigate, prefetch } from '../../../helpers/routing'; import { useAsync } from '../../../hooks/useAsync'; import { useConfig } from '../../../hooks/useConfig'; import { PageProps } from '../../../renderer/types'; +import { useQuery, useMutation } from 'urql'; const GetInvite = graphql(` query GetInvite($inviteId: ID!) { @@ -36,23 +36,21 @@ export const Page: FC = ({ routeParams }) => { const config = useConfig(); const createToast = useToasts(); const inviteToken = routeParams.inviteToken; - const invite = useQuery(GetInvite, { skip: !inviteToken, variables: { inviteId: inviteToken! } }); + const [invite] = useQuery({ query: GetInvite, pause: !inviteToken, variables: { inviteId: inviteToken! } }); const expiresAt = invite.data?.invite.expiresAt; useEffect(() => { prefetch('/login'); }, []); - const [createUserMutation] = useMutation(CreateUser); + const [, createUserMutation] = useMutation(CreateUser); const [onSubmit] = useAsync(async (data: SignupData) => { try { if (!inviteToken) return; await createUserMutation({ - variables: { - user: { - ...data, - invite: inviteToken, - }, + user: { + ...data, + invite: inviteToken, }, }); diff --git a/packages/web/src/pages/paste/+Page.tsx b/packages/web/src/pages/paste/+Page.tsx index 20e7e74..ccf6756 100644 --- a/packages/web/src/pages/paste/+Page.tsx +++ b/packages/web/src/pages/paste/+Page.tsx @@ -1,4 +1,3 @@ -import { useMutation } from '@apollo/client'; import { Form, Formik } from 'formik'; import { FC } from 'react'; import * as Yup from 'yup'; @@ -15,6 +14,7 @@ import { Title } from '../../components/title'; import { encryptContent } from '../../helpers/encrypt.helper'; import { useConfig } from '../../hooks/useConfig'; import { useUser } from '../../hooks/useUser'; +import { useMutation } from 'urql'; const EXPIRY_OPTIONS = [ { name: '15 minutes', value: 15 }, @@ -98,7 +98,7 @@ const CreatePaste = graphql(` export const Page: FC = () => { const user = useUser(); const config = useConfig(); - const [pasteMutation] = useMutation(CreatePaste); + const [, pasteMutation] = useMutation(CreatePaste); if (user.error) { return ; } @@ -148,12 +148,9 @@ export const Page: FC = () => { } const paste = await pasteMutation({ - variables: { - input: body, - }, + input: body, }); - if (paste.errors && paste.errors[0]) throw paste.errors[0]; const url = new URL(paste.data!.createPaste.urls.view); if (body.burn) url.searchParams.set('burn_unless', user.data.id); if (encryptionKey) url.hash = `key=${encryptionKey}`; diff --git a/packages/web/src/pages/paste/@pasteId/+Page.tsx b/packages/web/src/pages/paste/@pasteId/+Page.tsx index 6e4a2ba..630ca1e 100644 --- a/packages/web/src/pages/paste/@pasteId/+Page.tsx +++ b/packages/web/src/pages/paste/@pasteId/+Page.tsx @@ -1,4 +1,3 @@ -import { useQuery } from '@apollo/client'; import { FC, useEffect, useState } from 'react'; import { FiBookOpen, FiClock, FiTrash } from 'react-icons/fi'; import { graphql } from '../../../@generated'; @@ -14,6 +13,7 @@ import { hashToObject } from '../../../helpers/hash-to-object'; import { navigate } from '../../../helpers/routing'; import { useUser } from '../../../hooks/useUser'; import { PageProps } from '../../../renderer/types'; +import { useQuery } from 'urql'; const PasteQuery = graphql(` query GetPaste($pasteId: ID!) { @@ -47,8 +47,9 @@ export const Page: FC = ({ routeParams }) => { const skipQuery = !pasteId || (!confirmedBurn && (burnUnless === undefined || (burnUnless ? burnUnless !== user.data?.id : false))); - const paste = useQuery(PasteQuery, { - skip: skipQuery, + const [paste] = useQuery({ + query: PasteQuery, + pause: skipQuery, variables: { pasteId: pasteId!, }, diff --git a/packages/web/src/pages/shorten/+Page.tsx b/packages/web/src/pages/shorten/+Page.tsx index d0d6e40..adf4d38 100644 --- a/packages/web/src/pages/shorten/+Page.tsx +++ b/packages/web/src/pages/shorten/+Page.tsx @@ -1,4 +1,4 @@ -import { useMutation } from '@apollo/client'; +import { useMutation } from 'urql'; import { Form, Formik } from 'formik'; import { FC, useState } from 'react'; import * as Yup from 'yup'; @@ -30,7 +30,7 @@ const Shorten = graphql(` `); export const Page: FC = () => { - const [shortenMutation] = useMutation(Shorten); + const [, shortenMutation] = useMutation(Shorten); const [result, setResult] = useState(null); const config = useConfig(); @@ -48,10 +48,8 @@ export const Page: FC = () => { validationSchema={schema} onSubmit={async (values) => { const result = await shortenMutation({ - variables: { - link: values.url, - host: values.host, - }, + link: values.url, + host: values.host, }); setResult(result.data!.createLink.urls.view); diff --git a/packages/web/src/renderer/+onRenderClient.tsx b/packages/web/src/renderer/+onRenderClient.tsx index 26d2583..1fdee42 100644 --- a/packages/web/src/renderer/+onRenderClient.tsx +++ b/packages/web/src/renderer/+onRenderClient.tsx @@ -1,32 +1,42 @@ -import { ApolloClient, ApolloProvider, HttpLink, InMemoryCache } from '@apollo/client'; +import { createClient, fetchExchange, ssrExchange } from 'urql'; +import { Provider as UrqlProvider } from 'urql'; import { hydrateRoot } from 'react-dom/client'; +import { HelmetProvider } from 'react-helmet-async'; import { OnRenderClientAsync } from 'vike/types'; import { App } from '../app'; -import { typePolicies } from './policy'; import { PageContextProvider } from './usePageContext'; -import { HelmetProvider } from 'react-helmet-async'; +import { cacheOptions } from './cache'; +import { cacheExchange } from '@urql/exchange-graphcache'; export const onRenderClient: OnRenderClientAsync = async (pageContext) => { const { Page } = pageContext; - const client = new ApolloClient({ - link: new HttpLink({ uri: '/api/graphql' }), - cache: new InMemoryCache({ typePolicies }), + const ssr = ssrExchange({ isClient: true }); + const exchanges = [ssr, cacheExchange(cacheOptions), fetchExchange]; + + if (import.meta.env.MODE === 'development') { + const { devtoolsExchange } = await import('@urql/devtools'); + exchanges.unshift(devtoolsExchange); + } + + const client = createClient({ + url: '/api/graphql', + exchanges: exchanges, }); if (pageContext.state) { - client.cache.restore(pageContext.state); + ssr.restoreData(pageContext.state); } hydrateRoot( document.getElementById('root')!, - + - + , ); }; diff --git a/packages/web/src/renderer/+onRenderHtml.tsx b/packages/web/src/renderer/+onRenderHtml.tsx index 629a7e7..521ed48 100644 --- a/packages/web/src/renderer/+onRenderHtml.tsx +++ b/packages/web/src/renderer/+onRenderHtml.tsx @@ -1,12 +1,14 @@ -import { ApolloClient, ApolloProvider, HttpLink, InMemoryCache } from '@apollo/client'; -import { renderToStringWithData } from '@apollo/client/react/ssr'; +import { createClient, fetchExchange, ssrExchange } from 'urql'; import { HelmetProvider, HelmetServerState } from 'react-helmet-async'; +import { Provider as UrqlProvider } from 'urql'; import { dangerouslySkipEscape, escapeInject } from 'vike/server'; import type { OnRenderHtmlAsync } from 'vike/types'; import { App } from '../app'; -import { typePolicies } from './policy'; +import { renderToStringWithData } from './prepass'; import { PageProps } from './types'; import { PageContextProvider } from './usePageContext'; +import { cacheExchange } from '@urql/exchange-graphcache'; +import { cacheOptions } from './cache'; const GRAPHQL_URL = (import.meta.env.PUBLIC_ENV__FRONTEND_API_URL || import.meta.env.FRONTEND_API_URL) + '/graphql'; @@ -15,30 +17,30 @@ export const onRenderHtml: OnRenderHtmlAsync = async (pageContext): ReturnType - + - + ); - const pageHtml = await renderToStringWithData(tree); + const pageHtml = await renderToStringWithData(client, tree); const helmet = helmetContext.helmet!; const documentHtml = escapeInject` @@ -59,7 +61,7 @@ export const onRenderHtml: OnRenderHtmlAsync = async (pageContext): ReturnType = { + resolvers: { + User: { + files: relayPagination(), + pastes: relayPagination(), + }, + }, + keys: { + User: () => null, + Config: () => null, + ConfigHost: () => null, + FileMetadata: () => null, + ResourceLocations: () => null, + FilePage: () => null, + PastePage: () => null, + }, + updates: { + Mutation: { + disableOTP: (result, args, cache) => { + cache.invalidate('Query', 'user'); + }, + }, + }, +}; diff --git a/packages/web/src/renderer/policy.ts b/packages/web/src/renderer/policy.ts deleted file mode 100644 index a5a9983..0000000 --- a/packages/web/src/renderer/policy.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { TypePolicies } from '@apollo/client'; -import { relayStylePagination } from '@apollo/client/utilities'; - -export const typePolicies: TypePolicies = { - Config: { keyFields: [] }, - User: { - keyFields: [], - fields: { - files: relayStylePagination(), - pastes: relayStylePagination(), - }, - }, -}; diff --git a/packages/web/src/renderer/prepass.ts b/packages/web/src/renderer/prepass.ts new file mode 100644 index 0000000..4ed575e --- /dev/null +++ b/packages/web/src/renderer/prepass.ts @@ -0,0 +1,35 @@ +import { VNode } from 'preact'; +import renderToString from 'preact-render-to-string'; +import { Client } from 'urql'; + +const MAX_DEPTH = 3; +const isPromiseLike = (value: unknown): value is Promise => { + if (value && typeof (value as Promise).then === 'function') return true; + return false; +}; + +/** + * Enables urql suspense, then re-renders the tree until there are no suspense errors. + * This is a hack workaround because both `react-ssr-prepass` and `preact-ssr-prepass` are not working, both have preact/react compat errors. + */ +export const renderToStringWithData = async (client: Client, tree: VNode, depth = 0): Promise => { + try { + client.suspense = true; + const result = renderToString(tree); + client.suspense = false; + return result; + } catch (error) { + if (isPromiseLike(error)) { + if (depth > MAX_DEPTH) { + throw new Error( + `Exceeded max suspense depth. Try merge your queries so there are not ${MAX_DEPTH}+ on a single page.`, + ); + } + + await error; + return renderToStringWithData(client, tree, depth++); + } + + throw error; + } +}; diff --git a/packages/web/src/renderer/types.ts b/packages/web/src/renderer/types.ts index 662db9c..6ce6138 100644 --- a/packages/web/src/renderer/types.ts +++ b/packages/web/src/renderer/types.ts @@ -1,12 +1,12 @@ -import { NormalizedCacheObject } from '@apollo/client'; import { FC } from 'react'; +import { SSRData } from 'urql'; // https://vike.dev/pageContext#typescript declare global { namespace Vike { interface PageContext { Page: FC; - state?: NormalizedCacheObject; + state?: SSRData; pageHtml?: string; cookies?: string; } diff --git a/packages/web/tsconfig.json b/packages/web/tsconfig.json index cc662e8..1d80784 100644 --- a/packages/web/tsconfig.json +++ b/packages/web/tsconfig.json @@ -10,11 +10,6 @@ "skipLibCheck": true, "esModuleInterop": true, "composite": true, - "plugins": [ - { - "name": "@0no-co/graphqlsp", - "schema": "../api/src/schema.gql" - } - ] - } + "noUnusedLocals": true, + }, } diff --git a/packages/web/vite.config.ts b/packages/web/vite.config.ts index ad51bd3..b9bb6a0 100644 --- a/packages/web/vite.config.ts +++ b/packages/web/vite.config.ts @@ -15,10 +15,12 @@ export default defineConfig({ }, ], optimizeDeps: { - include: ['preact', 'preact/devtools', 'preact/debug', 'preact/jsx-dev-runtime', 'preact/hooks'], + include: ['preact', 'preact/devtools', 'preact/debug', 'preact/jsx-dev-runtime', 'preact/hooks', 'urql'], }, define: { 'process.env.NODE_ENV': '"production"' }, - ssr: { noExternal: ['@apollo/client', 'prism-react-renderer', 'qrcode.react', 'formik', 'react-helmet-async'] }, + ssr: { + noExternal: ['preact', 'urql', 'prism-react-renderer', 'qrcode.react', 'formik', 'react-helmet-async'], + }, plugins: [ preact(), ssr({ disableAutoFullBuild: true }), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b7b127..0e31c25 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -228,12 +228,6 @@ importers: packages/web: devDependencies: - '@0no-co/graphqlsp': - specifier: ^1.3.0 - version: 1.3.0 - '@apollo/client': - specifier: ^3.8.9 - version: 3.8.10(@preact/compat@17.1.2)(@preact/compat@17.1.2)(graphql@16.8.1) '@atlasbot/configs': specifier: ^10.5.15 version: 10.5.15(typescript@5.3.3) @@ -276,6 +270,12 @@ importers: '@types/react-dom': specifier: ^18.2.18 version: 18.2.18 + '@urql/devtools': + specifier: ^2.0.3 + version: 2.0.3(@urql/core@4.2.3)(graphql@16.8.1) + '@urql/exchange-graphcache': + specifier: ^6.4.1 + version: 6.4.1(graphql@16.8.1) autoprefixer: specifier: ^10.4.16 version: 10.4.17(postcss@8.4.33) @@ -363,6 +363,9 @@ importers: typescript: specifier: ^5.3.3 version: 5.3.3 + urql: + specifier: ^4.0.6 + version: 4.0.6(graphql@16.8.1) vavite: specifier: ^4.0.1 version: 4.0.2(vite@5.0.12) @@ -376,16 +379,17 @@ importers: specifier: ^1.3.3 version: 1.3.3 - packages/web/dist/server: {} - packages: - /@0no-co/graphqlsp@1.3.0: - resolution: {integrity: sha512-eL7ZAvAmncEgAIjVgGcyKsrpIOmJ4tQfWxTDVPKxiIgRdRA56K+qjW2HtGdxE/WlufnNpbwcizU9D2HddYg1Zg==} + /@0no-co/graphql.web@1.0.4(graphql@16.8.1): + resolution: {integrity: sha512-W3ezhHGfO0MS1PtGloaTpg0PbaT8aZSmmaerL7idtU5F7oCI+uu25k+MsMS31BVFlp4aMkHSrNRxiD72IlK8TA==} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 + peerDependenciesMeta: + graphql: + optional: true dependencies: - node-fetch: 2.7.0 - transitivePeerDependencies: - - encoding + graphql: 16.8.1 dev: true /@aashutoshrathi/word-wrap@1.2.6: @@ -406,41 +410,6 @@ packages: '@jridgewell/trace-mapping': 0.3.22 dev: true - /@apollo/client@3.8.10(@preact/compat@17.1.2)(@preact/compat@17.1.2)(graphql@16.8.1): - resolution: {integrity: sha512-p/22RZ8ehHyvySnC20EHPPe0gdu8Xp6ZCiXOfdEe1ZORw5cUteD/TLc66tfKv8qu8NLIfbiWoa+6s70XnKvxqg==} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 - graphql-ws: ^5.5.5 - react: '*' - react-dom: '*' - subscriptions-transport-ws: ^0.9.0 || ^0.11.0 - peerDependenciesMeta: - graphql-ws: - optional: true - react: - optional: true - react-dom: - optional: true - subscriptions-transport-ws: - optional: true - dependencies: - '@graphql-typed-document-node/core': 3.2.0(graphql@16.8.1) - '@wry/equality': 0.5.7 - '@wry/trie': 0.5.0 - graphql: 16.8.1 - graphql-tag: 2.12.6(graphql@16.8.1) - hoist-non-react-statics: 3.3.2 - optimism: 0.18.0 - prop-types: 15.8.1 - react: /@preact/compat@17.1.2(preact@10.19.3) - react-dom: /@preact/compat@17.1.2(preact@10.19.3) - response-iterator: 0.2.6 - symbol-observable: 4.0.0 - ts-invariant: 0.10.3 - tslib: 2.6.2 - zen-observable-ts: 1.2.5 - dev: true - /@ardatan/relay-compiler@12.0.0(graphql@16.8.1): resolution: {integrity: sha512-9anThAaj1dQr6IGmzBMcfzOQKTa5artjuPmw8NYK/fiGEMjADbSguBY2FMDykt+QhilR3wc9VA/3yVju7JHg7Q==} hasBin: true @@ -1434,7 +1403,7 @@ packages: '@fastify/reply-from': 9.7.0 fast-querystring: 1.1.2 fastify-plugin: 4.5.1 - ws: 8.16.0 + ws: 8.16.0(utf-8-validate@6.0.3) transitivePeerDependencies: - bufferutil - utf-8-validate @@ -1836,7 +1805,7 @@ packages: graphql-ws: 5.14.3(graphql@16.8.1) isomorphic-ws: 5.0.0(ws@8.16.0) tslib: 2.6.2 - ws: 8.16.0 + ws: 8.16.0(utf-8-validate@6.0.3) transitivePeerDependencies: - bufferutil - utf-8-validate @@ -1871,7 +1840,7 @@ packages: graphql: 16.8.1 isomorphic-ws: 5.0.0(ws@8.16.0) tslib: 2.6.2 - ws: 8.16.0 + ws: 8.16.0(utf-8-validate@6.0.3) transitivePeerDependencies: - bufferutil - utf-8-validate @@ -2123,7 +2092,7 @@ packages: isomorphic-ws: 5.0.0(ws@8.16.0) tslib: 2.6.2 value-or-promise: 1.0.12 - ws: 8.16.0 + ws: 8.16.0(utf-8-validate@6.0.3) transitivePeerDependencies: - '@types/node' - bufferutil @@ -4308,6 +4277,36 @@ packages: resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} dev: true + /@urql/core@4.2.3(graphql@16.8.1): + resolution: {integrity: sha512-DJ9q9+lcs5JL8DcU2J3NqsgeXYJva+1+Qt8HU94kzTPqVOIRRA7ouvy4ksUfPY+B5G2PQ+vLh+JJGyZCNXv0cg==} + dependencies: + '@0no-co/graphql.web': 1.0.4(graphql@16.8.1) + wonka: 6.3.4 + transitivePeerDependencies: + - graphql + dev: true + + /@urql/devtools@2.0.3(@urql/core@4.2.3)(graphql@16.8.1): + resolution: {integrity: sha512-TktPLiBS9LcBPHD6qcnb8wqOVcg3Bx0iCtvQ80uPpfofwwBGJmqnQTjUdEFU6kwaLOFZULQ9+Uo4831G823mQw==} + peerDependencies: + '@urql/core': '>= 1.14.0' + graphql: '>= 0.11.0' + dependencies: + '@urql/core': 4.2.3(graphql@16.8.1) + graphql: 16.8.1 + wonka: 6.3.4 + dev: true + + /@urql/exchange-graphcache@6.4.1(graphql@16.8.1): + resolution: {integrity: sha512-hWa4/5B7Op93oA6yWvffPU3L0XH55tlluEaq6aoFE9zsiNhktFjhp/SU2CKvSD8iD0Tfreoo63drv64Ad0+Gxw==} + dependencies: + '@0no-co/graphql.web': 1.0.4(graphql@16.8.1) + '@urql/core': 4.2.3(graphql@16.8.1) + wonka: 6.3.4 + transitivePeerDependencies: + - graphql + dev: true + /@vavite/connect@4.0.2(vite@5.0.12): resolution: {integrity: sha512-wEEjsKXUvmOEzQ5jJm33AAKmX9tNwjuxmTA5CHwHzria4QcjRUxgONo0jjhvIY4S+e9/gkf6e24rREjAGfgcfA==} peerDependencies: @@ -4453,41 +4452,6 @@ packages: tslib: 2.6.2 dev: true - /@wry/caches@1.0.1: - resolution: {integrity: sha512-bXuaUNLVVkD20wcGBWRyo7j9N3TxePEWFZj2Y+r9OoUzfqmavM84+mFykRicNsBqatba5JLay1t48wxaXaWnlA==} - engines: {node: '>=8'} - dependencies: - tslib: 2.6.2 - dev: true - - /@wry/context@0.7.4: - resolution: {integrity: sha512-jmT7Sb4ZQWI5iyu3lobQxICu2nC/vbUhP0vIdd6tHC9PTfenmRmuIFqktc6GH9cgi+ZHnsLWPvfSvc4DrYmKiQ==} - engines: {node: '>=8'} - dependencies: - tslib: 2.6.2 - dev: true - - /@wry/equality@0.5.7: - resolution: {integrity: sha512-BRFORjsTuQv5gxcXsuDXx6oGRhuVsEGwZy6LOzRRfgu+eSfxbhUQ9L9YtSEIuIjY/o7g3iWFjrc5eSY1GXP2Dw==} - engines: {node: '>=8'} - dependencies: - tslib: 2.6.2 - dev: true - - /@wry/trie@0.4.3: - resolution: {integrity: sha512-I6bHwH0fSf6RqQcnnXLJKhkSXG45MFral3GxPaY4uAl0LYDZM+YDVDAiU9bYwjTuysy1S0IeecWtmq1SZA3M1w==} - engines: {node: '>=8'} - dependencies: - tslib: 2.6.2 - dev: true - - /@wry/trie@0.5.0: - resolution: {integrity: sha512-FNoYzHawTMk/6KMQoEG5O4PuioX19UbwdQKF44yw0nLfOypfQdjtfZzo/UIJWAJ23sNIFbD1Ug9lbaDGMwbqQA==} - engines: {node: '>=8'} - dependencies: - tslib: 2.6.2 - dev: true - /abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} @@ -7333,7 +7297,7 @@ packages: peerDependencies: ws: '*' dependencies: - ws: 8.16.0 + ws: 8.16.0(utf-8-validate@6.0.3) dev: true /istextorbinary@9.5.0: @@ -8476,7 +8440,6 @@ packages: /node-gyp-build@4.8.0: resolution: {integrity: sha512-u6fs2AEUljNho3EYTJNBfImO5QTo/J/1Etd+NVdCj7qWKUSN/bSLkZwhDv7I+w/MSC6qJ4cknepkAYykDdK8og==} hasBin: true - dev: false /node-html-parser@6.1.12: resolution: {integrity: sha512-/bT/Ncmv+fbMGX96XG9g05vFt43m/+SYKIs9oAemQVYyVcZmDAI2Xq/SbNcpOA35eF0Zk2av3Ksf+Xk8Vt8abA==} @@ -8661,15 +8624,6 @@ packages: mimic-fn: 4.0.0 dev: true - /optimism@0.18.0: - resolution: {integrity: sha512-tGn8+REwLRNFnb9WmcY5IfpOqeX2kpaYJ1s6Ae3mn12AeydLkR3j+jSCmVQFoXqU8D41PAJ1RG1rCRNWmNZVmQ==} - dependencies: - '@wry/caches': 1.0.1 - '@wry/context': 0.7.4 - '@wry/trie': 0.4.3 - tslib: 2.6.2 - dev: true - /optionator@0.9.3: resolution: {integrity: sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==} engines: {node: '>= 0.8.0'} @@ -9613,11 +9567,6 @@ packages: supports-preserve-symlinks-flag: 1.0.0 dev: true - /response-iterator@0.2.6: - resolution: {integrity: sha512-pVzEEzrsg23Sh053rmDUvLSkGXluZio0qu8VT6ukrYuvtjVfCbDZH9d6PGXb8HZfzdNZt8feXv/jvUzlhRgLnw==} - engines: {node: '>=0.8'} - dev: true - /restore-cursor@3.1.0: resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} engines: {node: '>=8'} @@ -10225,11 +10174,6 @@ packages: engines: {node: '>=0.10.0'} dev: false - /symbol-observable@4.0.0: - resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==} - engines: {node: '>=0.10'} - dev: true - /syncpack@12.3.0(typescript@5.3.3): resolution: {integrity: sha512-Gz2uGn96OmGfVVlKztvFac1EJYjP+WptQ2ohA6Uf48C6qLkhSayhkdujKQ6q7bGOTy8HSGI0iDfwfCJu6wvRig==} engines: {node: '>=16'} @@ -10456,13 +10400,6 @@ packages: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} dev: true - /ts-invariant@0.10.3: - resolution: {integrity: sha512-uivwYcQaxAucv1CzRp2n/QdYPo4ILf9VXgH19zEIjFx2EJufV16P0JtJVpYHy89DItG6Kwj2oIUjrcK5au+4tQ==} - engines: {node: '>=8'} - dependencies: - tslib: 2.6.2 - dev: true - /ts-log@2.2.5: resolution: {integrity: sha512-PGcnJoTBnVGy6yYNFxWVNkdcAuAMstvutN9MgDJIV6L0oG8fB+ZNNy1T+wJzah8RPGor1mZuPQkVfXNDpy9eHA==} dev: true @@ -10916,6 +10853,15 @@ packages: resolution: {integrity: sha512-Qp95D4TPJl1kC9SKigDcqgyM2VDVO4RiJc2d4qe5GrYm+zbIQCWWKAFaJNQ4BhdFeDGwBmAxqJBwWSJDb9T3BQ==} dev: true + /urql@4.0.6(graphql@16.8.1): + resolution: {integrity: sha512-meXJ2puOd64uCGKh7Fse2R7gPa8+ZpBOoA62jN7CPXXUt7SVZSdeXWSpB3HvlfzLUkEqsWbvshwrgeWRYNNGaQ==} + dependencies: + '@urql/core': 4.2.3(graphql@16.8.1) + wonka: 6.3.4 + transitivePeerDependencies: + - graphql + dev: true + /use-callback-ref@1.3.1(@types/react@18.2.48): resolution: {integrity: sha512-Lg4Vx1XZQauB42Hw3kK7JM6yjVjgFmFC5/Ab797s79aARomD2nEErc4mCgM8EZrARLmmbWpi5DGCadmK50DcAQ==} engines: {node: '>=10'} @@ -10953,7 +10899,6 @@ packages: requiresBuild: true dependencies: node-gyp-build: 4.8.0 - dev: false /util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -11291,6 +11236,10 @@ packages: stackback: 0.0.2 dev: true + /wonka@6.3.4: + resolution: {integrity: sha512-CjpbqNtBGNAeyNS/9W6q3kSkKE52+FjIj7AkFlLr11s/VWGUu6a2CdYSdGxocIhIVjaW/zchesBQUKPVU69Cqg==} + dev: true + /wordwrap@1.0.0: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} dev: true @@ -11355,19 +11304,6 @@ packages: utf-8-validate: 6.0.3 dev: false - /ws@8.16.0: - resolution: {integrity: sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - dev: true - /ws@8.16.0(utf-8-validate@6.0.3): resolution: {integrity: sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==} engines: {node: '>=10.0.0'} @@ -11381,7 +11317,6 @@ packages: optional: true dependencies: utf-8-validate: 6.0.3 - dev: false /xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} @@ -11478,16 +11413,6 @@ packages: type-fest: 2.19.0 dev: true - /zen-observable-ts@1.2.5: - resolution: {integrity: sha512-QZWQekv6iB72Naeake9hS1KxHlotfRpe+WGNbNx5/ta+R3DNjVO2bswf63gXlWDcs+EMd7XY8HfVQyP1X6T4Zg==} - dependencies: - zen-observable: 0.8.15 - dev: true - - /zen-observable@0.8.15: - resolution: {integrity: sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ==} - dev: true - /zod-validation-error@2.1.0(zod@3.22.4): resolution: {integrity: sha512-VJh93e2wb4c3tWtGgTa0OF/dTt/zoPCPzXq4V11ZjxmEAFaPi/Zss1xIZdEB5RD8GD00U0/iVXgqkF77RV7pdQ==} engines: {node: '>=18.0.0'}