diff --git a/.changeset/silver-bananas-compare.md b/.changeset/silver-bananas-compare.md new file mode 100644 index 000000000..a02e17c32 --- /dev/null +++ b/.changeset/silver-bananas-compare.md @@ -0,0 +1,16 @@ +--- +'@quilted/react-query': patch +'@quilted/react-localize': patch +'@quilted/react-browser': patch +'@quilted/react-router': patch +'@quilted/react-async': patch +'@quilted/react-email': patch +'@quilted/react-dom': patch +'@quilted/browser': patch +'@quilted/graphql': patch +'@quilted/assets': patch +'@quilted/create': patch +'@quilted/quilt': patch +--- + +Refactor browser APIs diff --git a/documentation/features/http.md b/documentation/features/http.md index fd28b5d75..3f484a5dc 100644 --- a/documentation/features/http.md +++ b/documentation/features/http.md @@ -118,7 +118,7 @@ export function NotFoundUi() { } ``` -## Reading cookies and other request headers +## Reading cookies You can read cookies using the `useCookie` hook: @@ -139,25 +139,6 @@ export function GuardWithAuth({children}: PropsWithChildren) { On the server, these cookies are parsed from the `Cookie` request header. On the client, these cookies are parsed from `document.cookie`. -If you want to read other request headers, you can use the `useRequestHeader` hook: - -```tsx -import {useRequestHeader} from '@quilted/quilt/http'; - -export function CheckForBrotli() { - // Don’t worry, headers are normalized, so any capitalization works! - const acceptEncoding = useRequestHeader('Accept-Encoding') ?? ''; - - return acceptEncoding.includes('br') ? ( -
Request supports brotli!
- ) : ( -
Request does not support brotli :(
- ); -} -``` - -Typically, request headers are only available on the server-side. When you read them using the `useRequestHeader` hooks, though, they are serialized into the HTML document so that they are also available on the client. **Make sure that you are comfortable exposing this header to the client before using the `useRequestHeader` hook.** - ## Setting cookies and other response headers You can set an HTTP cookie by using the `useResponseCookie` hook or `ResponseCookie` component. Both accept the cookie name, value, and [other cookie options](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies). diff --git a/integrations/apollo/CHANGELOG.md b/integrations/apollo/CHANGELOG.md deleted file mode 100644 index 386cb3dec..000000000 --- a/integrations/apollo/CHANGELOG.md +++ /dev/null @@ -1,92 +0,0 @@ -# @quilted/apollo - -## 0.2.1 - -### Patch Changes - -- [#714](https://github.com/lemonmade/quilt/pull/714) [`d4bda43`](https://github.com/lemonmade/quilt/commit/d4bda430900d0e4afd5ccecb04abe9ac81245486) Thanks [@lemonmade](https://github.com/lemonmade)! - Update GraphQL dependencies - -- Updated dependencies [[`d4bda43`](https://github.com/lemonmade/quilt/commit/d4bda430900d0e4afd5ccecb04abe9ac81245486), [`8335c47`](https://github.com/lemonmade/quilt/commit/8335c47fa1896ad65d5cd218fe068f22627815d9)]: - - @quilted/quilt@0.6.14 - -## 0.2.0 - -### Minor Changes - -- [#645](https://github.com/lemonmade/quilt/pull/645) [`302ed847`](https://github.com/lemonmade/quilt/commit/302ed8479f9c035ef39d48137de958dba50690ca) Thanks [@lemonmade](https://github.com/lemonmade)! - Removed CommonJS support - - The `require` export condition is no longer provided by any package. Quilt only supports ESModules, so if you need to use the CommonJS version, you will need to pre-process Quilt’s output code on your own. - -### Patch Changes - -- Updated dependencies [[`302ed847`](https://github.com/lemonmade/quilt/commit/302ed8479f9c035ef39d48137de958dba50690ca)]: - - @quilted/quilt@0.6.0 - -## 0.1.8 - -### Patch Changes - -- [#571](https://github.com/lemonmade/quilt/pull/571) [`3bdd0dd3`](https://github.com/lemonmade/quilt/commit/3bdd0dd39654e64e52465c46aea95c7c06f2e1cb) Thanks [@lemonmade](https://github.com/lemonmade)! - Clean up GraphQL library for a V1 - -- Updated dependencies [[`3bdd0dd3`](https://github.com/lemonmade/quilt/commit/3bdd0dd39654e64e52465c46aea95c7c06f2e1cb)]: - - @quilted/quilt@0.5.144 - -## 0.1.7 - -### Patch Changes - -- [`839c33f6`](https://github.com/lemonmade/quilt/commit/839c33f6d22a5db0d97989e8c6ef9fa049698182) Thanks [@lemonmade](https://github.com/lemonmade)! - Random assortment of other dependency updates - -- Updated dependencies [[`839c33f6`](https://github.com/lemonmade/quilt/commit/839c33f6d22a5db0d97989e8c6ef9fa049698182)]: - - @quilted/quilt@0.5.143 - -## 0.1.6 - -### Patch Changes - -- [`97812120`](https://github.com/lemonmade/quilt/commit/978121207c65a4450a8ca9e43d017c6425a315c3) Thanks [@lemonmade](https://github.com/lemonmade)! - Update Preact dependencies and fix some missing peer dependencies - -- Updated dependencies [[`97812120`](https://github.com/lemonmade/quilt/commit/978121207c65a4450a8ca9e43d017c6425a315c3)]: - - @quilted/quilt@0.5.140 - -## 0.1.5 - -### Patch Changes - -- [#536](https://github.com/lemonmade/quilt/pull/536) [`cf6e2de1`](https://github.com/lemonmade/quilt/commit/cf6e2de186d8644fad9afcedda85c05002e909e1) Thanks [@lemonmade](https://github.com/lemonmade)! - Update to TypeScript 5.0 - -- Updated dependencies [[`cf6e2de1`](https://github.com/lemonmade/quilt/commit/cf6e2de186d8644fad9afcedda85c05002e909e1)]: - - @quilted/quilt@0.5.139 - -## 0.1.4 - -### Patch Changes - -- [`8f1d275b`](https://github.com/lemonmade/quilt/commit/8f1d275b6de0abbc6f61bcd5401555f6480eb474) Thanks [@lemonmade](https://github.com/lemonmade)! - Remove need for @babel/runtime peer dependency - -- Updated dependencies [[`8f1d275b`](https://github.com/lemonmade/quilt/commit/8f1d275b6de0abbc6f61bcd5401555f6480eb474), [`50215b7c`](https://github.com/lemonmade/quilt/commit/50215b7c005c21440bca04935fda87d98d9d9d01)]: - - @quilted/quilt@0.5.125 - -## 0.1.3 - -### Patch Changes - -- [#475](https://github.com/lemonmade/quilt/pull/475) [`de6bb615`](https://github.com/lemonmade/quilt/commit/de6bb615c1cdb763f9116e0649b21d6c46aaf9a4) Thanks [@lemonmade](https://github.com/lemonmade)! - Update to React 18 - -- Updated dependencies [[`de6bb615`](https://github.com/lemonmade/quilt/commit/de6bb615c1cdb763f9116e0649b21d6c46aaf9a4)]: - - @quilted/quilt@0.5.124 - -## 0.1.2 - -### Patch Changes - -- [#474](https://github.com/lemonmade/quilt/pull/474) [`8890fad8`](https://github.com/lemonmade/quilt/commit/8890fad8d04efa95b362f4beaefcdbd51e65ba04) Thanks [@lemonmade](https://github.com/lemonmade)! - Looser React version restrictions - -- Updated dependencies [[`8890fad8`](https://github.com/lemonmade/quilt/commit/8890fad8d04efa95b362f4beaefcdbd51e65ba04)]: - - @quilted/quilt@0.5.123 - -## 0.1.1 - -### Patch Changes - -- [`95c93f43`](https://github.com/lemonmade/quilt/commit/95c93f43b3dffa4e4024695e5a142171c942280d) Thanks [@lemonmade](https://github.com/lemonmade)! - Fix infinite loop in ApolloProvider diff --git a/integrations/apollo/README.md b/integrations/apollo/README.md deleted file mode 100644 index ca7432983..000000000 --- a/integrations/apollo/README.md +++ /dev/null @@ -1,86 +0,0 @@ -# `@quilted/apollo` - -Integrates [Apollo](https://www.apollographql.com/docs/react) with Quilt by automatically running your GraphQL queries during server-side rendering. - -## Installation - -Install `@quilted/apollo`, `@apollo/client`, and `graphql` as dependencies of your project: - -```bash -$ pnpm add @quilted/apollo @apollo/client graphql --save -``` - -> **Note:** This library needs [`@quilted/quilt`](../../packages/quilt) installed in your local repository. If you have just [created a new Quilt app](../../documentation/getting-started.md), you already have this installed. - -## Usage - -[Apollo’s getting started instructions](https://www.apollographql.com/docs/react/get-started) instruct you to create an `ApolloClient` object, and pass it to a `ApolloProvider` component. To integrate Apollo with Quilt, you will pass your `ApolloClient` object to this library’s `ApolloProvider` component instead: - -```tsx -import {useMemo} from 'react'; -import {useInitialUrl} from '@quilted/quilt'; - -import {ApolloClient, InMemoryCache} from '@apollo/client'; -import {ApolloProvider} from '@quilted/apollo/client'; - -export default function App() { - const initialUrl = useInitialUrl(); - const client = useMemo(() => { - // Replace this with the URL of your actual API. To resolve queries during server-side - // rendering, this must be an absolute URL. - const url = new URL('/graphql', initialUrl); - - const cache = new InMemoryCache(); - - return new ApolloClient({ - cache, - uri: url.href, - // Make sure to enable SSR mode so that Apollo runs the queries during server-side rendering. - // @see https://www.apollographql.com/docs/react/api/core/ApolloClient/#ssrmode - ssrMode: true, - }); - }, []); - - return ( - - - - ); -} -``` - -The `ApolloProvider` takes care of ensuring that all queries made by your application are run during server-side rendering. It serializes the results into your HTML payload, and restores that data in the Apollo Client before rendering your app in the browser. It also renders Apollo’s `ApolloProvider` for you, so you don’t need to do it yourself. - -That’s all the setup you need! Elsewhere in your application, you can now use Apollo’s [`useQuery` hook](https://www.apollographql.com/docs/react/api/react/hooks#usequery) to load data in your components. The example below shows how you might use Quilt’s GraphQL utilities to perform type-safe GraphQL queries using React Query: - -```tsx -import {gql, useQuery} from '@apollo/client'; - -export function Start() { - const {data, loading} = useQuery<{hello: string}>(gql` - query { - hello - } - `); - - return loading ?

Loading…

:

{data?.hello ?? 'Server error…'}

; -} -``` - -This library also provides a `parseDocument()` utility function that takes the small build outputs Quilt creates for `.graphql` files, and turns it into a type-safe `TypedDocumentNode` that Apollo needs to run your operations. This allows you to preserve Quilt’s [type-safe GraphQL utilities](../../documentation/features/graphql.md#types) through Apollo’s `useQuery()` and `useMutation()` hooks. - -```tsx -import {useQuery} from '@apollo/client'; -import {parseDocument} from '@quilted/apollo/client'; - -import helloQuerySource from './HelloQuery.graphql'; - -const helloQuery = parseDocument(helloQuerySource); - -export function Start() { - // Apollo will automatically know the shape of the data returned by this query! - const {data, loading} = useQuery(helloQuery); - - return loading ?

Loading…

:

{data?.hello ?? 'Server error…'}

; -} -``` diff --git a/integrations/apollo/package.json b/integrations/apollo/package.json deleted file mode 100644 index 7622da1fe..000000000 --- a/integrations/apollo/package.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "name": "@quilted/apollo", - "description": "Integrates Apollo with Quilt", - "type": "module", - "license": "MIT", - "publishConfig": { - "access": "public", - "@quilted/registry": "https://registry.npmjs.org" - }, - "version": "0.2.1", - "engines": { - "node": ">=14.0.0" - }, - "repository": { - "type": "git", - "url": "https://github.com/lemonmade/quilt", - "directory": "integrations/apollo" - }, - "exports": { - ".": { - "types": "./build/typescript/index.d.ts", - "quilt:source": "./source/index.ts", - "quilt:esnext": "./build/esnext/index.esnext", - "import": "./build/esm/index.mjs" - }, - "./client": { - "types": "./build/typescript/client.d.ts", - "quilt:source": "./source/client.ts", - "quilt:esnext": "./build/esnext/client.esnext", - "import": "./build/esm/client.mjs" - } - }, - "types": "./build/typescript/index.d.ts", - "typesVersions": { - "*": { - "client": [ - "./build/typescript/client.d.ts" - ] - } - }, - "sideEffects": false, - "scripts": { - "build": "rollup --config configuration/rollup.config.js" - }, - "dependencies": { - "@types/react": "^17.0.0 || ^18.0.0" - }, - "peerDependencies": { - "@apollo/client": "workspace:^3.0.0", - "@quilted/quilt": "workspace:^0.6.14", - "graphql": "^16.8.0", - "react": "^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@apollo/client": { - "optional": false - }, - "@quilted/quilt": { - "optional": true - }, - "graphql": { - "optional": false - }, - "react": { - "optional": true - } - }, - "devDependencies": { - "@apollo/client": "^3.7.0", - "@quilted/quilt": "workspace:^0.6.14", - "graphql": "^16.8.0", - "react": "workspace:@quilted/react@^18.2.0" - } -} diff --git a/integrations/apollo/source/ApolloProvider.tsx b/integrations/apollo/source/ApolloProvider.tsx deleted file mode 100644 index ab51b742a..000000000 --- a/integrations/apollo/source/ApolloProvider.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import {useMemo, useRef, type ComponentProps} from 'react'; -import { - getApolloContext, - ApolloProvider as BaseApolloProvider, -} from '@apollo/client'; -import {RenderPromises} from '@apollo/client/react/ssr'; -import {useSerialized} from '@quilted/quilt/html'; - -export function ApolloProvider({ - id = 'Quilt.Apollo', - client, - children, - ...rest -}: ComponentProps & {id?: string}) { - const ApolloContext = getApolloContext(); - const apolloContext = useMemo(() => { - return {renderPromises: new RenderPromises()}; - }, []); - - const data = useSerialized(id, () => { - if (apolloContext.renderPromises.hasPromises()) { - return apolloContext.renderPromises - .consumeAndAwaitPromises() - .then(() => client.extract()); - } - - return client.extract(); - }); - - const restoreRef = useRef(); - - if (data && !restoreRef.current) { - restoreRef.current = true; - client.restore(data); - } - - return ( - - - {children} - - - ); -} diff --git a/integrations/apollo/source/client.ts b/integrations/apollo/source/client.ts deleted file mode 100644 index 67e09d575..000000000 --- a/integrations/apollo/source/client.ts +++ /dev/null @@ -1,2 +0,0 @@ -export {ApolloProvider} from './ApolloProvider.tsx'; -export {parseDocument} from './parse.ts'; diff --git a/integrations/apollo/source/index.ts b/integrations/apollo/source/index.ts deleted file mode 100644 index cb0ff5c3b..000000000 --- a/integrations/apollo/source/index.ts +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/integrations/apollo/source/parse.ts b/integrations/apollo/source/parse.ts deleted file mode 100644 index 2f30350f2..000000000 --- a/integrations/apollo/source/parse.ts +++ /dev/null @@ -1,9 +0,0 @@ -import {parse} from 'graphql'; -import {type GraphQLOperation} from '@quilted/quilt/graphql'; -import {type TypedDocumentNode} from '@apollo/client'; - -export function parseDocument({ - source, -}: GraphQLOperation): TypedDocumentNode { - return parse(source) as any; -} diff --git a/integrations/react-query/source/ReactQueryContext.tsx b/integrations/react-query/source/ReactQueryContext.tsx index b6a4d621f..cbf230908 100644 --- a/integrations/react-query/source/ReactQueryContext.tsx +++ b/integrations/react-query/source/ReactQueryContext.tsx @@ -1,59 +1,59 @@ -import {useMemo, type PropsWithChildren} from 'react'; +import type {PropsWithChildren} from 'react'; import { QueryClientProvider, QueryClient, dehydrate, HydrationBoundary, + type DehydratedState, } from '@tanstack/react-query'; -import {useSerialized} from '@quilted/quilt/html'; +import {useSerialized} from '@quilted/quilt/browser'; +import {Serialize} from '@quilted/quilt/server'; -export interface ReactQueryServerRenderer { - fetchAll(): Promise | undefined; -} +const SERIALIZATION_ID = 'quilt:react-query'; export function ReactQueryContext({ client, children, }: PropsWithChildren<{client: QueryClient}>) { - const serverRenderer = useMemo(() => { - return { - fetchAll() { - const promises: Promise[] = []; - - for (const query of client.getQueryCache().getAll()) { - const {state, options, meta} = query; - - if (state.status === 'success' || state.status === 'error') continue; - if ((options as any).enabled === false) continue; - if (meta != null && meta.server === false) continue; - - promises.push(query.fetch()); - } - - if (promises.length > 0) { - return Promise.all(promises).then(() => {}); - } - }, - }; - }, [client]); - - const hydrationState = useSerialized('ReactQuery', () => { - const fetched = serverRenderer.fetchAll(); - - return typeof fetched === 'object' - ? fetched.then(() => finalize()) - : finalize(); - - function finalize() { - return dehydrate(client, { - shouldDehydrateQuery: () => true, - }) as Record; - } - }); + const dehydratedState = useSerialized( + SERIALIZATION_ID, + ); return ( - {children} + {children} + ); } + +function Serializer({client}: {client: QueryClient}) { + if (typeof document === 'object') return null; + + const promises: Promise[] = []; + + for (const query of client.getQueryCache().getAll()) { + const {state, options, meta} = query; + + if (state.status === 'success' || state.status === 'error') continue; + if ((options as any).enabled === false) continue; + if (meta != null && meta.server === false) continue; + + promises.push(query.fetch()); + } + + if (promises.length > 0) { + throw Promise.all(promises).then(() => {}); + } + + return ( + + dehydrate(client, { + shouldDehydrateQuery: () => true, + }) + } + /> + ); +} diff --git a/integrations/react-query/source/use-graphql-query.ts b/integrations/react-query/source/use-graphql-query.ts index 41fffacc5..ffaf87bd3 100644 --- a/integrations/react-query/source/use-graphql-query.ts +++ b/integrations/react-query/source/use-graphql-query.ts @@ -1,7 +1,7 @@ import { - useQuery, - type UseQueryOptions, - type UseQueryResult, + useSuspenseQuery, + type UseSuspenseQueryOptions, + type UseSuspenseQueryResult, } from '@tanstack/react-query'; import { toGraphQLSource, @@ -15,10 +15,10 @@ import {type IfAllFieldsNullable} from '@quilted/useful-types'; import {throwIfError} from './utilities.ts'; export type GraphQLQueryOptions = Omit< - UseQueryOptions, + UseSuspenseQueryOptions, 'queryFn' | 'queryKey' > & - Partial, 'queryFn' | 'queryKey'>> & + Partial, 'queryFn' | 'queryKey'>> & GraphQLVariableOptions & { fetch?: GraphQLFetch; }; @@ -30,7 +30,7 @@ export function useGraphQLQuery( [GraphQLQueryOptions?], [GraphQLQueryOptions] > -): UseQueryResult { +): UseSuspenseQueryResult { const [options = {} as any] = args; const { @@ -68,7 +68,7 @@ export function useGraphQLQuery( } } - return useQuery({ + return useSuspenseQuery({ queryKey: fullQueryKey, queryFn: async ({signal}) => { const result = await fetch(query, { diff --git a/package.json b/package.json index d513cbe47..9c2b4a7e6 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "typescript.run": "tsx --conditions quilt:source", "typescript.watch": "tsx watch --conditions quilt:source" }, - "packageManager": "pnpm@9.0.4+sha256.caa915eaae9d9aefccf50ee8aeda25a2f8684d8f9d5c6e367eaf176d97c1f89e", + "packageManager": "pnpm@9.0.6+sha256.0624e30eff866cdeb363b15061bdb7fd9425b17bc1bb42c22f5f4efdea21f6b3", "devDependencies": { "@actions/core": "^1.10.0", "@actions/github": "^5.1.0", diff --git a/packages/assets/source/attributes.ts b/packages/assets/source/attributes.ts index f073017b9..556f938d1 100644 --- a/packages/assets/source/attributes.ts +++ b/packages/assets/source/attributes.ts @@ -2,7 +2,7 @@ import type {Asset} from './types.ts'; export function styleAssetAttributes( {source, attributes}: Asset, - {baseUrl}: {baseUrl?: URL} = {}, + {baseURL}: {baseURL?: URL} = {}, ): Partial { const { rel = 'stylesheet', @@ -14,11 +14,11 @@ export function styleAssetAttributes( const crossorigin = explicitCrossOrigin ?? (source[0] !== '/' && - (baseUrl == null || !source.startsWith(baseUrl.origin)) && + (baseURL == null || !source.startsWith(baseURL.origin)) && isFullURL(source)); const href = - crossorigin && baseUrl ? source.slice(baseUrl.origin.length) : source; + crossorigin && baseURL ? source.slice(baseURL.origin.length) : source; return { rel, @@ -36,18 +36,18 @@ export function styleAssetAttributes( export function styleAssetPreloadAttributes( {source, attributes}: Asset, - {baseUrl}: {baseUrl?: URL} = {}, + {baseURL}: {baseURL?: URL} = {}, ): Partial { const {crossorigin: explicitCrossOrigin} = (attributes ?? {}) as any; const crossorigin = explicitCrossOrigin ?? (source[0] !== '/' && - (baseUrl == null || !source.startsWith(baseUrl.origin)) && + (baseURL == null || !source.startsWith(baseURL.origin)) && isFullURL(source)); const href = - crossorigin && baseUrl ? source.slice(baseUrl.origin.length) : source; + crossorigin && baseURL ? source.slice(baseURL.origin.length) : source; return { rel: 'preload', @@ -65,7 +65,7 @@ export function styleAssetPreloadAttributes( export function scriptAssetAttributes( {source, attributes}: Asset, - {baseUrl}: {baseUrl?: URL} = {}, + {baseURL}: {baseURL?: URL} = {}, ): Partial { const { type = 'text/javascript', @@ -76,11 +76,11 @@ export function scriptAssetAttributes( const crossorigin = explicitCrossOrigin ?? (source[0] !== '/' && - (baseUrl == null || !source.startsWith(baseUrl.origin)) && + (baseURL == null || !source.startsWith(baseURL.origin)) && isFullURL(source)); const src = - crossorigin && baseUrl ? source.slice(baseUrl.origin.length) : source; + crossorigin && baseURL ? source.slice(baseURL.origin.length) : source; return { type, @@ -97,18 +97,18 @@ export function scriptAssetAttributes( export function scriptAssetPreloadAttributes( {source, attributes}: Asset, - {baseUrl}: {baseUrl?: URL} = {}, + {baseURL}: {baseURL?: URL} = {}, ): Partial { const {type, crossorigin: explicitCrossOrigin} = (attributes ?? {}) as any; const crossorigin = explicitCrossOrigin ?? (source[0] !== '/' && - (baseUrl == null || !source.startsWith(baseUrl.origin)) && + (baseURL == null || !source.startsWith(baseURL.origin)) && isFullURL(source)); const href = - crossorigin && baseUrl ? source.slice(baseUrl.origin.length) : source; + crossorigin && baseURL ? source.slice(baseURL.origin.length) : source; return { rel: type === 'module' ? 'modulepreload' : 'preload', diff --git a/packages/browser/README.md b/packages/browser/README.md new file mode 100644 index 000000000..2a488a43b --- /dev/null +++ b/packages/browser/README.md @@ -0,0 +1 @@ +# `@quilted/react-browser` diff --git a/packages/react-html/package.json b/packages/browser/package.json similarity index 59% rename from packages/react-html/package.json rename to packages/browser/package.json index ca3f52725..26eb562d7 100644 --- a/packages/react-html/package.json +++ b/packages/browser/package.json @@ -1,18 +1,20 @@ { - "name": "@quilted/react-html", - "description": "Provides components and hooks for interacting with the HTML document.", + "name": "@quilted/browser", "type": "module", - "version": "0.4.1", + "license": "MIT", + "publishConfig": { + "access": "public", + "@quilted/registry": "https://registry.npmjs.org" + }, + "version": "0.0.0", + "engines": { + "node": ">=18.0.0" + }, "repository": { "type": "git", "url": "https://github.com/lemonmade/quilt.git", - "directory": "packages/react-html" + "directory": "packages/react-browser" }, - "publishConfig": { - "access": "public", - "@quilted:registry": "https://registry.npmjs.org" - }, - "license": "MIT", "exports": { ".": { "types": "./build/typescript/index.d.ts", @@ -20,17 +22,17 @@ "quilt:esnext": "./build/esnext/index.esnext", "import": "./build/esm/index.mjs" }, - "./testing": { - "types": "./build/typescript/testing.d.ts", - "quilt:source": "./source/testing.ts", - "quilt:esnext": "./build/esnext/testing.esnext", - "import": "./build/esm/testing.mjs" - }, "./server": { "types": "./build/typescript/server.d.ts", "quilt:source": "./source/server.ts", "quilt:esnext": "./build/esnext/server.esnext", "import": "./build/esm/server.mjs" + }, + "./testing": { + "types": "./build/typescript/testing.d.ts", + "quilt:source": "./source/testing.ts", + "quilt:esnext": "./build/esnext/testing.esnext", + "import": "./build/esm/testing.mjs" } }, "types": "./build/typescript/index.d.ts", @@ -46,28 +48,20 @@ }, "sideEffects": false, "scripts": { - "build": "rollup --config configuration/rollup.config.js" - }, - "peerDependencies": { - "react": "^17.0.0 || ^18.0.0", - "react-dom": "^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-dom": { - "optional": true - } + "build": "rollup --config ./rollup.config.js" }, "dependencies": { - "@quilted/assets": "workspace:^0.1.0", - "@quilted/react-server-render": "workspace:^0.4.0", - "@types/react": "^17.0.0 || ^18.0.0" + "@quilted/assets": "workspace:^0.1.1", + "@quilted/signals": "workspace:^0.2.1", + "js-cookie": "^3.0.0" }, + "peerDependencies": {}, + "peerDependenciesMeta": {}, "devDependencies": { - "@types/react-dom": "^18.2.0", - "react": "workspace:@quilted/react@^18.2.0", - "react-dom": "workspace:@quilted/react-dom@^18.2.0" - } + "@types/js-cookie": "^3.0.0" + }, + "browserslist": [ + "defaults and fully supports es6-module", + "maintained node versions" + ] } diff --git a/integrations/apollo/configuration/rollup.config.js b/packages/browser/rollup.config.js similarity index 100% rename from integrations/apollo/configuration/rollup.config.js rename to packages/browser/rollup.config.js diff --git a/packages/browser/source/browser.ts b/packages/browser/source/browser.ts new file mode 100644 index 000000000..a22369628 --- /dev/null +++ b/packages/browser/source/browser.ts @@ -0,0 +1,267 @@ +import JSCookie from 'js-cookie'; +import { + effect, + signal, + isSignal, + type Signal, + type ReadonlySignal, + resolveSignalOrValue, +} from '@quilted/signals'; + +import type {BrowserDetails, CookieOptions, Cookies} from './types.ts'; + +export class Browser implements BrowserDetails { + readonly title = new BrowserTitle(); + readonly metas = new BrowserHeadElements('meta'); + readonly links = new BrowserHeadElements('link'); + readonly htmlAttributes = new BrowserElementAttributes( + document.documentElement, + ); + readonly bodyAttributes = new BrowserElementAttributes(document.body); + readonly cookies = new BrowserCookies(); + readonly serializations = new BrowserSerializations(); + readonly request = new Request(window.location.href); +} + +export class BrowserCookies implements Cookies { + private readonly cookieSignals = signal( + new Map>( + Object.entries(JSCookie.get()).map(([cookie, value]) => [ + cookie, + signal(value), + ]), + ), + ); + + has(cookie: string) { + return this.cookieSignals.value.get(cookie)?.value != null; + } + + get(cookie: string) { + return this.cookieSignals.value.get(cookie)?.value; + } + + set(cookie: string, value: string, options?: CookieOptions) { + JSCookie.set(cookie, value, options); + this.updateCookie(cookie); + } + + delete(cookie: string, options?: CookieOptions) { + JSCookie.remove(cookie, options); + this.updateCookie(cookie); + } + + *entries() { + const cookies = this.cookieSignals.peek(); + + for (const [cookie, signal] of cookies) { + yield [cookie, signal.peek()] as const; + } + } + + *[Symbol.iterator]() { + yield* this.cookieSignals.peek().keys(); + } + + private updateCookie(cookie: string) { + const value = JSCookie.get(cookie); + const cookieSignals = this.cookieSignals.peek(); + const cookieSignal = cookieSignals.get(cookie); + + if (value) { + if (cookieSignal) { + cookieSignal.value = value; + } else { + const newCookie = signal(value); + const newCookies = new Map(cookieSignals); + newCookies.set(cookie, newCookie); + this.cookieSignals.value = newCookies; + } + } else if (cookieSignal) { + const newCookies = new Map(cookieSignals); + newCookies.delete(cookie); + this.cookieSignals.value = newCookies; + } + } +} + +export class BrowserTitle { + private titleElement = document.head.querySelector('title'); + private titleValues = signal[]>([]); + + add = (title: string | ReadonlySignal) => { + const titleSignal = isSignal(title) ? title : signal(title); + const newTitleValues = [...this.titleValues.peek(), titleSignal]; + this.titleValues.value = newTitleValues; + return () => { + this.titleValues.value = this.titleValues.value.filter( + (existingTitle) => existingTitle !== titleSignal, + ); + }; + }; + + constructor() { + effect(() => { + const title = this.titleValues.value.at(-1)?.value; + if (title == null) return; + + if (this.titleElement) { + this.titleElement.textContent = title; + } else { + this.titleElement = document.createElement('title'); + this.titleElement.textContent = title; + document.head.appendChild(this.titleElement); + } + }); + } +} + +export class BrowserHeadElements { + private initialElements: readonly HTMLElementTagNameMap[Element][]; + + constructor(readonly element: Element) { + this.initialElements = Array.from(document.head.querySelectorAll(element)); + } + + add = ( + attributes: + | Partial + | ReadonlySignal>, + ) => { + const element = document.createElement(this.element); + + setAttributes(element, attributes); + + const existingElement = this.initialElements.find((existingElement) => { + return element.isEqualNode(existingElement); + }); + + const resolvedElement = existingElement ?? element; + + if (!existingElement) { + document.head.appendChild(resolvedElement); + } + + let teardown: undefined | (() => void); + + if (isSignal(attributes)) { + teardown = syncAttributesFromSignal(resolvedElement, attributes); + } + + return () => { + resolvedElement.remove(); + teardown?.(); + }; + }; +} + +export class BrowserElementAttributes { + constructor(readonly element: Element) {} + + add(attributes: Partial | ReadonlySignal>) { + const {element} = this; + + setAttributes(element, attributes); + + let teardown: undefined | (() => void); + + if (isSignal(attributes)) { + teardown = syncAttributesFromSignal(element, attributes); + } + + return () => { + teardown?.(); + + for (const attribute of Object.keys(resolveSignalOrValue(attributes))) { + element.removeAttribute(attribute); + } + }; + } +} + +export class BrowserSerializations { + private readonly serializations = new Map( + Array.from( + document.querySelectorAll(`meta[name^="serialized"]`), + ).map((node) => [ + node.name.replace(/^serialized-/, ''), + getSerializedFromNode(node), + ]), + ); + + get(id: string) { + return this.serializations.get(id) as any; + } + + set(id: string, data: unknown) { + if (data === undefined) { + this.serializations.delete(id); + } else { + this.serializations.set(id, data); + } + } + + *[Symbol.iterator]() { + yield* this.serializations; + } +} + +function getSerializedFromNode(node: Element): T | undefined { + const value = (node as HTMLMetaElement).content; + + try { + return value ? JSON.parse(value) : undefined; + } catch { + return undefined; + } +} + +function setAttributes( + element: Element, + attributes: Record | ReadonlySignal>, +) { + const resolvedAttributes = isSignal>(attributes) + ? attributes.peek() + : attributes; + + for (const [attribute, value] of Object.entries(resolvedAttributes)) { + setAttribute(element, attribute, value); + } +} + +function setAttribute(element: Element, attribute: string, value: any) { + if (attribute in element) { + (element as any)[attribute] = value; + } else { + element.setAttribute(attribute, value); + } +} + +function syncAttributesFromSignal( + element: Element, + attributes: ReadonlySignal>, +) { + let lastAttributesEntries: [string, any][]; + + return effect(() => { + const updatedAttributes = attributes.value; + + if (!lastAttributesEntries) return; + + const updatedAttributeEntries = Object.entries(updatedAttributes); + const seenAttributes = new Set(); + + for (const [attribute, value] of updatedAttributeEntries) { + seenAttributes.add(attribute); + setAttribute(element, attribute, value); + } + + for (const [attribute] of lastAttributesEntries) { + if (!seenAttributes.has(attribute)) { + setAttribute(element, attribute, undefined); + } + } + + lastAttributesEntries = updatedAttributeEntries; + }); +} diff --git a/packages/browser/source/index.ts b/packages/browser/source/index.ts new file mode 100644 index 000000000..08dde23c5 --- /dev/null +++ b/packages/browser/source/index.ts @@ -0,0 +1,2 @@ +export * from './types.ts'; +export {Browser, BrowserCookies} from './browser.ts'; diff --git a/packages/browser/source/server.ts b/packages/browser/source/server.ts new file mode 100644 index 000000000..4661679f4 --- /dev/null +++ b/packages/browser/source/server.ts @@ -0,0 +1,261 @@ +import {resolveSignalOrValue, type ReadonlySignal} from '@quilted/signals'; + +import type { + BrowserDetails, + BrowserBodyAttributes, + BrowserHTMLAttributes, + CookieOptions, + Cookies, +} from './types.ts'; +import type { + AssetLoadTiming, + AssetsCacheKey, + BrowserAssetModuleSelector, +} from '@quilted/assets'; + +import {CookieString} from './shared/cookies.ts'; + +export * from './types.ts'; + +export class BrowserResponse implements BrowserDetails { + readonly title = new BrowserResponseTitle(); + readonly metas = new BrowserResponseHeadElements('meta'); + readonly links = new BrowserResponseHeadElements('link'); + readonly bodyAttributes = + new BrowserResponseElementAttributes(); + readonly htmlAttributes = + new BrowserResponseElementAttributes(); + readonly status: BrowserResponseStatus; + readonly cookies: BrowserResponseCookies; + readonly serializations: BrowserResponseSerializations; + readonly headers: Headers; + readonly request: Request; + readonly assets: BrowserResponseAssets; + + constructor({ + request, + status, + headers = new Headers(), + cacheKey, + serializations, + }: { + request: Request; + status?: number; + headers?: Headers; + cacheKey?: Partial; + serializations?: Iterable<[string, unknown]>; + }) { + this.request = request; + this.status = new BrowserResponseStatus(status); + this.headers = headers; + this.cookies = new BrowserResponseCookies( + headers, + request.headers.get('Cookie') ?? undefined, + ); + this.assets = new BrowserResponseAssets({cacheKey}); + this.serializations = new BrowserResponseSerializations( + new Map(serializations), + ); + } +} + +export class BrowserResponseCookies implements Cookies { + private readonly cookies: Record; + + constructor( + private readonly headers: Headers, + cookie?: string, + ) { + this.cookies = CookieString.parse(cookie ?? ''); + } + + has(cookie: string) { + return this.cookies[cookie] != null; + } + + get(cookie: string) { + return this.cookies[cookie]; + } + + set(cookie: string, value: string, options?: CookieOptions) { + this.headers.append( + 'Set-Cookie', + CookieString.serialize(cookie, value, options), + ); + } + + delete(cookie: string, options?: CookieOptions) { + this.set(cookie, '', {expires: new Date(0), ...options}); + } + + *entries() { + yield* Object.entries(this.cookies); + } + + *[Symbol.iterator]() { + yield* Object.keys(this.cookies); + } +} + +export class BrowserResponseStatus { + constructor(private statusCode?: number) {} + + get value() { + return this.statusCode ?? 200; + } + + set(value: number) { + this.statusCode = Math.max(value, this.statusCode ?? 0); + } +} + +export class BrowserResponseTitle { + private lastTitle?: string | ReadonlySignal; + + get value() { + return resolveSignalOrValue(this.lastTitle); + } + + add = (title: string | ReadonlySignal) => { + this.lastTitle = title; + return () => {}; + }; +} + +export class BrowserResponseHeadElements< + Element extends keyof HTMLElementTagNameMap, +> { + private readonly elements: ( + | Partial + | ReadonlySignal> + )[] = []; + + get value() { + return this.elements.map(resolveSignalOrValue); + } + + constructor(readonly selector: Element) {} + + add = ( + attributes: + | Partial + | ReadonlySignal>, + ) => { + this.elements.push(attributes); + return () => {}; + }; +} + +export class BrowserResponseElementAttributes { + private readonly attributes: (Attributes | ReadonlySignal)[] = []; + + get value() { + return Object.assign({}, ...this.attributes.map(resolveSignalOrValue)); + } + + add = (attributes: Attributes | ReadonlySignal) => { + this.attributes.push(attributes); + return () => {}; + }; +} + +export class BrowserResponseSerializations { + constructor(private readonly serializations = new Map()) {} + + get(id: string) { + return this.serializations.get(id) as any; + } + + set(id: string, data: unknown) { + if (data === undefined) { + this.serializations.delete(id); + } else { + this.serializations.set(id, data); + } + } + + *[Symbol.iterator]() { + yield* this.serializations; + } +} + +const ASSET_TIMING_PRIORITY: AssetLoadTiming[] = ['never', 'preload', 'load']; + +const PRIORITY_BY_TIMING = new Map( + ASSET_TIMING_PRIORITY.map((value, index) => [value, index]), +); + +export class BrowserResponseAssets { + readonly cacheKey: Partial; + private usedModulesWithTiming = new Map< + string, + { + styles: AssetLoadTiming; + scripts: AssetLoadTiming; + } + >(); + + constructor({cacheKey}: {cacheKey?: Partial} = {}) { + this.cacheKey = {...cacheKey}; + } + + updateCacheKey(cacheKey: Partial) { + Object.assign(this.cacheKey, cacheKey); + } + + use( + id: string, + { + timing = 'load', + scripts = timing, + styles = timing, + }: { + timing?: AssetLoadTiming; + scripts?: AssetLoadTiming; + styles?: AssetLoadTiming; + } = {}, + ) { + const current = this.usedModulesWithTiming.get(id); + + if (current == null) { + this.usedModulesWithTiming.set(id, { + scripts, + styles, + }); + } else { + this.usedModulesWithTiming.set(id, { + scripts: + scripts == null + ? current.scripts + : highestPriorityAssetLoadTiming(scripts, current.scripts), + styles: + styles == null + ? current.styles + : highestPriorityAssetLoadTiming(styles, current.styles), + }); + } + } + + get({timing = 'load'}: {timing?: AssetLoadTiming | AssetLoadTiming[]} = {}) { + const allowedTiming = Array.isArray(timing) ? timing : [timing]; + + const assets: BrowserAssetModuleSelector[] = []; + + for (const [asset, {scripts, styles}] of this.usedModulesWithTiming) { + const stylesMatch = allowedTiming.includes(styles); + const scriptsMatch = allowedTiming.includes(scripts); + + if (stylesMatch || scriptsMatch) { + assets.push({id: asset, styles: stylesMatch, scripts: scriptsMatch}); + } + } + + return assets; + } +} + +function highestPriorityAssetLoadTiming(...timings: AssetLoadTiming[]) { + return ASSET_TIMING_PRIORITY[ + Math.max(...timings.map((timing) => PRIORITY_BY_TIMING.get(timing)!)) + ]!; +} diff --git a/packages/browser/source/shared/cookies.ts b/packages/browser/source/shared/cookies.ts new file mode 100644 index 000000000..61a8aba97 --- /dev/null +++ b/packages/browser/source/shared/cookies.ts @@ -0,0 +1,126 @@ +// What follows is a basic re-implementation of https://www.npmjs.com/package/cookie. +// That library only uses CommonJS, which makes for some awkward build issues. + +import {CookieOptions} from '../types.ts'; + +/** + * RegExp to match field-content in RFC 7230 sec 3.2 + * + * field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ] + * field-vchar = VCHAR / obs-text + * obs-text = %x80-FF + */ + +const FIELD_CONTENT_REG_EXP = /^[\u0009\u0020-\u007e\u0080-\u00ff]+$/; + +export const CookieString = { + parse(str: string) { + const cookies: Record = {}; + const pairs = str.split(';'); + + for (const pair of pairs) { + const index = pair.indexOf('='); + + // skip things that don't look like key=value + if (index < 0) continue; + + const key = pair.substring(0, index).trim(); + + // only assign once + if (cookies[key] == null) { + let value = pair.substring(index + 1, pair.length).trim(); + + // quoted values + if (value[0] === '"') { + value = value.slice(1, -1); + } + + cookies[key] = tryDecode(value); + } + } + + return cookies; + }, + serialize(name: string, rawValue: string, options: CookieOptions = {}) { + if (!FIELD_CONTENT_REG_EXP.test(name)) { + throw new TypeError('argument name is invalid'); + } + + const value = encodeURIComponent(rawValue); + + if (value && !FIELD_CONTENT_REG_EXP.test(value)) { + throw new TypeError('argument val is invalid'); + } + + let cookie = name + '=' + value; + + if (options.maxAge != null) { + const maxAge = options.maxAge; + + if (isNaN(maxAge) || !isFinite(maxAge)) { + throw new TypeError('option maxAge is invalid'); + } + + cookie += '; Max-Age=' + Math.floor(maxAge); + } + + if (options.domain) { + if (!FIELD_CONTENT_REG_EXP.test(options.domain)) { + throw new TypeError('option domain is invalid'); + } + + cookie += '; Domain=' + options.domain; + } + + if (options.path) { + if (!FIELD_CONTENT_REG_EXP.test(options.path)) { + throw new TypeError('option path is invalid'); + } + + cookie += '; Path=' + options.path; + } + + if (options.expires) { + cookie += '; Expires=' + options.expires.toUTCString(); + } + + if (options.httpOnly) { + cookie += '; HttpOnly'; + } + + if (options.secure) { + cookie += '; Secure'; + } + + if (options.sameSite) { + const sameSite = + typeof options.sameSite === 'string' + ? options.sameSite.toLowerCase() + : options.sameSite; + + switch (sameSite) { + case 'lax': + cookie += '; SameSite=Lax'; + break; + case 'strict': + cookie += '; SameSite=Strict'; + break; + case 'none': + cookie += '; SameSite=None'; + break; + default: + throw new TypeError('option sameSite is invalid'); + } + } + + return cookie; + }, +}; + +function tryDecode(str: string) { + try { + return decodeURIComponent(str); + } catch (e) { + return str; + } +} diff --git a/packages/browser/source/testing.ts b/packages/browser/source/testing.ts new file mode 100644 index 000000000..43bff78e8 --- /dev/null +++ b/packages/browser/source/testing.ts @@ -0,0 +1,186 @@ +import {resolveSignalOrValue, type ReadonlySignal} from '@quilted/signals'; + +import type { + BrowserDetails, + BrowserBodyAttributes, + BrowserHTMLAttributes, + CookieOptions, + Cookies, +} from './types.ts'; + +import {CookieString} from './shared/cookies.ts'; + +export * from './types.ts'; + +export class BrowserTestMock implements BrowserDetails { + readonly title = new BrowserTestMockTitle(); + readonly metas = new BrowserTestMockHeadElements('meta'); + readonly links = new BrowserTestMockHeadElements('link'); + readonly bodyAttributes = + new BrowserTestMockElementAttributes(); + readonly htmlAttributes = + new BrowserTestMockElementAttributes(); + readonly cookies: BrowserTestMockCookies; + readonly serializations: BrowserTestMockSerializations; + readonly request: Request; + + constructor({ + url, + cookies, + serializations, + }: { + url?: URL | string; + cookies?: ConstructorParameters[0]; + serializations?: ConstructorParameters< + typeof BrowserTestMockSerializations + >[0]; + }) { + this.request = new Request( + url ?? + (typeof location === 'object' ? location.href : 'https://example.com'), + ); + this.cookies = new BrowserTestMockCookies(cookies); + this.serializations = new BrowserTestMockSerializations(serializations); + } +} + +export class BrowserTestMockCookies implements Cookies { + readonly updates: ({cookie: string; value: string} & CookieOptions)[] = []; + private readonly cookies: Map; + + constructor( + cookie: + | string + | Record + | Iterable = [], + ) { + const cookieObject = + typeof cookie === 'string' ? CookieString.parse(cookie) : cookie; + + this.cookies = new Map( + Symbol.iterator in cookieObject + ? cookieObject + : Object.entries(cookieObject), + ); + } + + has(cookie: string) { + return this.cookies.has(cookie); + } + + get(cookie: string) { + return this.cookies.get(cookie); + } + + set(cookie: string, value: string, options?: CookieOptions) { + this.cookies.set(cookie, value); + this.updates.push({cookie, value, ...options}); + } + + delete(cookie: string, options?: CookieOptions) { + this.cookies.delete(cookie); + this.updates.push({cookie, value: '', expires: new Date(0), ...options}); + } + + *entries() { + yield* Object.entries(this.cookies); + } + + *[Symbol.iterator]() { + yield* Object.keys(this.cookies); + } +} + +export class BrowserTestMockTitle { + private titleValues: (string | ReadonlySignal)[] = []; + + get value() { + return resolveSignalOrValue(this.titleValues.at(-1)); + } + + add = (title: string | ReadonlySignal) => { + this.titleValues.push(title); + + return () => { + const index = this.titleValues.indexOf(title); + if (index >= 0) this.titleValues.splice(index, 1); + }; + }; +} + +export class BrowserTestMockHeadElements< + Element extends keyof HTMLElementTagNameMap, +> { + private readonly elements: ( + | Partial + | ReadonlySignal> + )[] = []; + + get value() { + return this.elements.map(resolveSignalOrValue); + } + + constructor(readonly selector: Element) {} + + add = ( + attributes: + | Partial + | ReadonlySignal>, + ) => { + this.elements.push(attributes); + + return () => { + const index = this.elements.indexOf(attributes); + if (index >= 0) this.elements.splice(index, 1); + }; + }; +} + +export class BrowserTestMockElementAttributes { + private readonly attributes: (Attributes | ReadonlySignal)[] = []; + + get value() { + return Object.assign({}, ...this.attributes.map(resolveSignalOrValue)); + } + + add = (attributes: Attributes | ReadonlySignal) => { + this.attributes.push(attributes); + + return () => { + const index = this.attributes.indexOf(attributes); + if (index >= 0) this.attributes.splice(index, 1); + }; + }; +} + +export class BrowserTestMockSerializations { + private readonly serializations: Map; + + constructor( + serializations: + | Record + | Iterable = [], + ) { + this.serializations = new Map( + Symbol.iterator in serializations + ? serializations + : Object.entries(serializations), + ); + } + + get(id: string) { + return this.serializations.get(id) as any; + } + + set(id: string, data: unknown) { + if (data === undefined) { + this.serializations.delete(id); + } else { + this.serializations.set(id, data); + } + } + + *[Symbol.iterator]() { + yield* this.serializations; + } +} diff --git a/packages/browser/source/types.ts b/packages/browser/source/types.ts new file mode 100644 index 000000000..03cda76fe --- /dev/null +++ b/packages/browser/source/types.ts @@ -0,0 +1,102 @@ +import type {ReadonlySignal} from '@quilted/signals'; + +export type BrowserMetaAttributes = Partial; +export type BrowserLinkAttributes = Partial; +export type BrowserHTMLAttributes = Partial; +export type BrowserBodyAttributes = Partial; + +export interface BrowserDetails { + readonly title: { + add(title: string | ReadonlySignal): () => void; + }; + readonly metas: { + add( + attributes: BrowserMetaAttributes | ReadonlySignal, + ): () => void; + }; + readonly links: { + add( + attributes: BrowserLinkAttributes | ReadonlySignal, + ): () => void; + }; + readonly serializations: { + get(id: string): T; + set(id: string, data: unknown): void; + [Symbol.iterator](): IterableIterator<[string, unknown]>; + }; + readonly htmlAttributes: { + add( + attributes: BrowserHTMLAttributes | ReadonlySignal, + ): () => void; + }; + readonly bodyAttributes: { + add( + attributes: BrowserBodyAttributes | ReadonlySignal, + ): () => void; + }; + readonly cookies: Cookies; + readonly request: Request; +} + +/** + * A wrapper around the cookies for a website, either on the server + * or client. + */ +export interface Cookies extends CookiesReadonly, CookiesWritable {} + +/** + * Additional options that can be passed when setting a cookie. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies + */ +export interface CookieOptions { + path?: string; + domain?: string; + maxAge?: number; + expires?: Date; + sameSite?: 'lax' | 'strict' | 'none'; + secure?: boolean; + httpOnly?: boolean; +} + +export interface CookiesReadonly { + /** + * Returns whether the provided cookie exists. + */ + has(cookie: string): boolean; + + /** + * Gets the current value of the provided cookie. If the cookie does + * not exist, this method returns `undefined`. + */ + get(cookie: string): string | undefined; + + /** + * Iterates over all the cookies, with each iteration receiving a tuple + * of `[cookieName, cookieValue]`, both of which are strings. + */ + entries(): IterableIterator; + + /** + * Iterates over all the cookie values that have been set. + */ + [Symbol.iterator](): IterableIterator; +} + +export interface CookiesWritable { + /** + * Sets a cookie to the provided value. You can pass options to + * customize the behavior of the cookie as the third argument. + */ + set(cookie: string, value: string, options?: CookieOptions): void; + + /** + * Deletes the provided cookie. If you set your cookie on a custom + * `path` or `domain`, you will need to provide a matching `path` and + * `domain` as the second argument to this method. + */ + delete( + cookie: string, + options?: Pick, + ): void; +} diff --git a/integrations/apollo/tsconfig.json b/packages/browser/tsconfig.json similarity index 74% rename from integrations/apollo/tsconfig.json rename to packages/browser/tsconfig.json index ed0ab12b6..27b90069e 100644 --- a/integrations/apollo/tsconfig.json +++ b/packages/browser/tsconfig.json @@ -6,5 +6,5 @@ }, "include": ["source"], "exclude": [], - "references": [{"path": "../../packages/quilt"}] + "references": [{"path": "../assets"}, {"path": "../signals"}] } diff --git a/packages/browser/vite.config.js b/packages/browser/vite.config.js new file mode 100644 index 000000000..403ebba65 --- /dev/null +++ b/packages/browser/vite.config.js @@ -0,0 +1,6 @@ +import {defineConfig} from 'vite'; +import {quiltPackage} from '@quilted/vite/package'; + +export default defineConfig({ + plugins: [quiltPackage()], +}); diff --git a/packages/create/templates/app-basic/App.tsx b/packages/create/templates/app-basic/App.tsx index 3dc6edcc5..1e2d91b1a 100644 --- a/packages/create/templates/app-basic/App.tsx +++ b/packages/create/templates/app-basic/App.tsx @@ -1,11 +1,9 @@ import {type PropsWithChildren} from 'react'; -import {HTML} from '@quilted/quilt/html'; import {Routing, useRoutes} from '@quilted/quilt/navigate'; import {Localization, useLocaleFromEnvironment} from '@quilted/quilt/localize'; -import {Head} from './foundation/html.ts'; -import {Headers} from './foundation/http.ts'; +import {HTML} from './foundation/html.ts'; import {Frame} from './foundation/frame.ts'; import {Start} from './features/start.ts'; @@ -15,27 +13,21 @@ import { type AppContext as AppContextType, } from './shared/context.ts'; -export interface AppProps extends AppContextType {} +export interface AppProps { + context?: AppContextType; +} // The root component for your application. You will typically render any // app-wide context in this component. -export function App(props: AppProps) { - const locale = useLocaleFromEnvironment() ?? 'en'; - +export function App({context}: AppProps) { return ( - - - - - - - - - - - - - + + + + + + + ); } @@ -50,10 +42,14 @@ function Routes() { } // This component renders any app-wide context. -function AppContext({children, ...context}: PropsWithChildren) { +function AppContext({children, context}: PropsWithChildren) { + const locale = useLocaleFromEnvironment() ?? 'en'; + return ( - {children} + + {children} + ); } diff --git a/packages/create/templates/app-basic/browser.tsx b/packages/create/templates/app-basic/browser.tsx index e57250460..36f4b6eb7 100644 --- a/packages/create/templates/app-basic/browser.tsx +++ b/packages/create/templates/app-basic/browser.tsx @@ -1,8 +1,15 @@ import '@quilted/quilt/globals'; import {hydrateRoot} from 'react-dom/client'; +import {Browser, BrowserContext} from '@quilted/quilt/browser'; import {App} from './App.tsx'; const element = document.querySelector('#app')!; +const browser = new Browser(); -hydrateRoot(element, ); +hydrateRoot( + element, + + + , +); diff --git a/packages/create/templates/app-basic/foundation/html.ts b/packages/create/templates/app-basic/foundation/html.ts index af771dbeb..4b307d8bc 100644 --- a/packages/create/templates/app-basic/foundation/html.ts +++ b/packages/create/templates/app-basic/foundation/html.ts @@ -1 +1 @@ -export {Head} from './html/Head.tsx'; +export {HTML} from './html/HTML.tsx'; diff --git a/packages/create/templates/app-basic/foundation/html/HTML.test.tsx b/packages/create/templates/app-basic/foundation/html/HTML.test.tsx new file mode 100644 index 000000000..76b4054fc --- /dev/null +++ b/packages/create/templates/app-basic/foundation/html/HTML.test.tsx @@ -0,0 +1,46 @@ +import {describe, it, expect} from 'vitest'; +import { + CacheControl, + ContentSecurityPolicy, + SearchRobots, + Viewport, +} from '@quilted/quilt/server'; + +import {renderApp} from '~/tests/render.ts'; + +import {HTML} from './HTML.tsx'; + +describe('', () => { + it('includes a responsive viewport tag', async () => { + const head = await renderApp(); + + expect(head).toContainReactComponent(Viewport, { + cover: true, + }); + }); + + it('prevents search robots from indexing the application', async () => { + const head = await renderApp(); + + expect(head).toContainReactComponent(SearchRobots, { + index: false, + follow: false, + }); + }); + + it('does not cache the response', async () => { + const headers = await renderApp(); + + expect(headers).toContainReactComponent(CacheControl, { + cache: false, + }); + }); + + it('adds a content security policy with a strict default policy', async () => { + const headers = await renderApp(); + + expect(headers).toContainReactComponent(ContentSecurityPolicy, { + defaultSources: ["'self'"], + }); + }); +}); diff --git a/packages/create/templates/app-trpc/foundation/http/Headers.tsx b/packages/create/templates/app-basic/foundation/html/HTML.tsx similarity index 75% rename from packages/create/templates/app-trpc/foundation/http/Headers.tsx rename to packages/create/templates/app-basic/foundation/html/HTML.tsx index 5c744f438..48331a591 100644 --- a/packages/create/templates/app-trpc/foundation/http/Headers.tsx +++ b/packages/create/templates/app-basic/foundation/html/HTML.tsx @@ -1,21 +1,34 @@ -import Env from '@quilted/quilt/env'; -import {useInitialUrl} from '@quilted/quilt/navigate'; +import type {PropsWithChildren} from 'react'; +import {Title, Favicon, useBrowserRequest} from '@quilted/quilt/browser'; import { CacheControl, ResponseHeader, ContentSecurityPolicy, PermissionsPolicy, + SearchRobots, StrictTransportSecurity, -} from '@quilted/quilt/http'; + Viewport, +} from '@quilted/quilt/server'; -// This component sets details on the HTTP response for all HTML server-rendering -// requests. If you need to customize any of these details based on conditions like -// the active route, or some state about the user, you can move these components to -// wherever in your application you can read that state. +// This component sets details of the HTML page. If you need to customize +// any of these details based on conditions like the active route, or some +// state about the user, you can move these components to wherever in your +// application you can read that state. // -// @see https://github.com/lemonmade/quilt/blob/main/documentation/features/http.md -export function Headers() { - const isHttps = useInitialUrl().protocol === 'https:'; +// @see https://github.com/lemonmade/quilt/blob/main/documentation/features/html.md +export function HTML({children}: PropsWithChildren) { + return ( + <> + + + {children} + + ); +} + +function Headers() { + const {url} = useBrowserRequest(); + const isHttps = new URL(url).protocol === 'https:'; return ( <> @@ -116,3 +129,30 @@ export function Headers() { ); } + +function Head() { + return ( + <> + {/* Sets the default `` for this application. */} + <Title>App + + {/* + * Sets the default favicon used by the application. You can + * change this to a different emoji, make it `blank`, or pass + * a URL with the `source` prop. + */} + + + {/* Adds a responsive-friendly `viewport` `` tag. */} + + + {/* + * Disables all search indexing for this application. If you are + * building an unauthenticated app, you probably want to remove + * this component, or update it to control how your site is indexed + * by search engines. + */} + + + ); +} diff --git a/packages/create/templates/app-basic/foundation/html/Head.test.tsx b/packages/create/templates/app-basic/foundation/html/Head.test.tsx deleted file mode 100644 index 038e62e5c..000000000 --- a/packages/create/templates/app-basic/foundation/html/Head.test.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import {describe, it, expect} from 'vitest'; -import {Viewport, SearchRobots} from '@quilted/quilt/html'; - -import {renderApp} from '~/tests/render.ts'; - -import {Head} from './Head.tsx'; - -describe('', () => { - it('includes a responsive viewport tag', async () => { - const head = await renderApp(); - - expect(head).toContainReactComponent(Viewport, { - cover: true, - }); - }); - - it('prevents search robots from indexing the application', async () => { - const head = await renderApp(); - - expect(head).toContainReactComponent(SearchRobots, { - index: false, - follow: false, - }); - }); -}); diff --git a/packages/create/templates/app-basic/foundation/html/Head.tsx b/packages/create/templates/app-basic/foundation/html/Head.tsx deleted file mode 100644 index a52e54f21..000000000 --- a/packages/create/templates/app-basic/foundation/html/Head.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import {Title, Viewport, Favicon, SearchRobots} from '@quilted/quilt/html'; - -// This component sets details of the HTML page. If you need to customize -// any of these details based on conditions like the active route, or some -// state about the user, you can move these components to wherever in your -// application you can read that state. -// -// @see https://github.com/lemonmade/quilt/blob/main/documentation/features/html.md -export function Head() { - return ( - <> - {/* Sets the default `` for this application. */} - <Title>App - - {/* - * Sets the default favicon used by the application. You can - * change this to a different emoji, make it `blank`, or pass - * a URL with the `source` prop. - */} - - - {/* Adds a responsive-friendly `viewport` `` tag. */} - - - {/* - * Disables all search indexing for this application. If you are - * building an unauthenticated app, you probably want to remove - * this component, or update it to control how your site is indexed - * by search engines. - */} - - - ); -} diff --git a/packages/create/templates/app-basic/foundation/http.ts b/packages/create/templates/app-basic/foundation/http.ts deleted file mode 100644 index 2e03263ac..000000000 --- a/packages/create/templates/app-basic/foundation/http.ts +++ /dev/null @@ -1 +0,0 @@ -export {Headers} from './http/Headers.tsx'; diff --git a/packages/create/templates/app-basic/foundation/http/Headers.test.tsx b/packages/create/templates/app-basic/foundation/http/Headers.test.tsx deleted file mode 100644 index 362599d7a..000000000 --- a/packages/create/templates/app-basic/foundation/http/Headers.test.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import {describe, it, expect} from 'vitest'; -import {CacheControl, ContentSecurityPolicy} from '@quilted/quilt/http'; - -import {renderApp} from '~/tests/render.ts'; - -import {Headers} from './Headers.tsx'; - -describe('', () => { - it('does not cache the response', async () => { - const headers = await renderApp(); - - expect(headers).toContainReactComponent(CacheControl, { - cache: false, - }); - }); - - it('adds a content security policy with a strict default policy', async () => { - const headers = await renderApp(); - - expect(headers).toContainReactComponent(ContentSecurityPolicy, { - defaultSources: ["'self'"], - }); - }); -}); diff --git a/packages/create/templates/app-graphql/App.tsx b/packages/create/templates/app-graphql/App.tsx index bcbe7dd6f..359f908c9 100644 --- a/packages/create/templates/app-graphql/App.tsx +++ b/packages/create/templates/app-graphql/App.tsx @@ -1,15 +1,12 @@ -import {useMemo, type PropsWithChildren} from 'react'; +import {type PropsWithChildren} from 'react'; -import {HTML} from '@quilted/quilt/html'; import {Routing, useRoutes} from '@quilted/quilt/navigate'; import {Localization, useLocaleFromEnvironment} from '@quilted/quilt/localize'; -import {GraphQLContext, type GraphQLRun} from '@quilted/quilt/graphql'; +import {GraphQLContext} from '@quilted/quilt/graphql'; import {ReactQueryContext} from '@quilted/react-query'; -import {QueryClient} from '@tanstack/react-query'; -import {Head} from './foundation/html.ts'; -import {Headers} from './foundation/http.ts'; +import {HTML} from './foundation/html.ts'; import {Frame} from './foundation/frame.ts'; import {Start} from './features/start.ts'; @@ -19,29 +16,21 @@ import { type AppContext as AppContextType, } from './shared/context.ts'; -export interface AppProps extends AppContextType { - fetchGraphQL: GraphQLRun; +export interface AppProps { + context: AppContextType; } // The root component for your application. You will typically render any // app-wide context in this component. -export function App(props: AppProps) { - const locale = useLocaleFromEnvironment() ?? 'en'; - +export function App({context}: AppProps) { return ( - - - - - - - - - - - - - + + + + + + + ); } @@ -56,24 +45,18 @@ function Routes() { } // This component renders any app-wide context. -function AppContext({ - children, - fetchGraphQL, - ...context -}: PropsWithChildren) { - const {queryClient} = useMemo(() => { - return { - queryClient: new QueryClient(), - }; - }, []); +function AppContext({children, context}: PropsWithChildren) { + const locale = useLocaleFromEnvironment() ?? 'en'; return ( - - - - {children} - - - + + + + + {children} + + + + ); } diff --git a/packages/create/templates/app-graphql/browser.tsx b/packages/create/templates/app-graphql/browser.tsx index c60343e29..6640ab112 100644 --- a/packages/create/templates/app-graphql/browser.tsx +++ b/packages/create/templates/app-graphql/browser.tsx @@ -1,13 +1,21 @@ import '@quilted/quilt/globals'; import {hydrateRoot} from 'react-dom/client'; +import {QueryClient} from '@tanstack/react-query'; import {createGraphQLFetch} from '@quilted/quilt/graphql'; +import {Browser, BrowserContext} from '@quilted/quilt/browser'; import {App} from './App.tsx'; const element = document.querySelector('#app')!; +const browser = new Browser(); + +const queryClient = new QueryClient(); +const fetchGraphQL = createGraphQLFetch({url: '/api/graphql'}); hydrateRoot( element, - , + + + , ); diff --git a/packages/create/templates/app-graphql/foundation/html.ts b/packages/create/templates/app-graphql/foundation/html.ts index af771dbeb..4b307d8bc 100644 --- a/packages/create/templates/app-graphql/foundation/html.ts +++ b/packages/create/templates/app-graphql/foundation/html.ts @@ -1 +1 @@ -export {Head} from './html/Head.tsx'; +export {HTML} from './html/HTML.tsx'; diff --git a/packages/create/templates/app-graphql/foundation/html/HTML.test.tsx b/packages/create/templates/app-graphql/foundation/html/HTML.test.tsx new file mode 100644 index 000000000..76b4054fc --- /dev/null +++ b/packages/create/templates/app-graphql/foundation/html/HTML.test.tsx @@ -0,0 +1,46 @@ +import {describe, it, expect} from 'vitest'; +import { + CacheControl, + ContentSecurityPolicy, + SearchRobots, + Viewport, +} from '@quilted/quilt/server'; + +import {renderApp} from '~/tests/render.ts'; + +import {HTML} from './HTML.tsx'; + +describe('', () => { + it('includes a responsive viewport tag', async () => { + const head = await renderApp(); + + expect(head).toContainReactComponent(Viewport, { + cover: true, + }); + }); + + it('prevents search robots from indexing the application', async () => { + const head = await renderApp(); + + expect(head).toContainReactComponent(SearchRobots, { + index: false, + follow: false, + }); + }); + + it('does not cache the response', async () => { + const headers = await renderApp(); + + expect(headers).toContainReactComponent(CacheControl, { + cache: false, + }); + }); + + it('adds a content security policy with a strict default policy', async () => { + const headers = await renderApp(); + + expect(headers).toContainReactComponent(ContentSecurityPolicy, { + defaultSources: ["'self'"], + }); + }); +}); diff --git a/packages/create/templates/app-basic/foundation/http/Headers.tsx b/packages/create/templates/app-graphql/foundation/html/HTML.tsx similarity index 75% rename from packages/create/templates/app-basic/foundation/http/Headers.tsx rename to packages/create/templates/app-graphql/foundation/html/HTML.tsx index 5c744f438..48331a591 100644 --- a/packages/create/templates/app-basic/foundation/http/Headers.tsx +++ b/packages/create/templates/app-graphql/foundation/html/HTML.tsx @@ -1,21 +1,34 @@ -import Env from '@quilted/quilt/env'; -import {useInitialUrl} from '@quilted/quilt/navigate'; +import type {PropsWithChildren} from 'react'; +import {Title, Favicon, useBrowserRequest} from '@quilted/quilt/browser'; import { CacheControl, ResponseHeader, ContentSecurityPolicy, PermissionsPolicy, + SearchRobots, StrictTransportSecurity, -} from '@quilted/quilt/http'; + Viewport, +} from '@quilted/quilt/server'; -// This component sets details on the HTTP response for all HTML server-rendering -// requests. If you need to customize any of these details based on conditions like -// the active route, or some state about the user, you can move these components to -// wherever in your application you can read that state. +// This component sets details of the HTML page. If you need to customize +// any of these details based on conditions like the active route, or some +// state about the user, you can move these components to wherever in your +// application you can read that state. // -// @see https://github.com/lemonmade/quilt/blob/main/documentation/features/http.md -export function Headers() { - const isHttps = useInitialUrl().protocol === 'https:'; +// @see https://github.com/lemonmade/quilt/blob/main/documentation/features/html.md +export function HTML({children}: PropsWithChildren) { + return ( + <> + + + {children} + + ); +} + +function Headers() { + const {url} = useBrowserRequest(); + const isHttps = new URL(url).protocol === 'https:'; return ( <> @@ -116,3 +129,30 @@ export function Headers() { ); } + +function Head() { + return ( + <> + {/* Sets the default `` for this application. */} + <Title>App + + {/* + * Sets the default favicon used by the application. You can + * change this to a different emoji, make it `blank`, or pass + * a URL with the `source` prop. + */} + + + {/* Adds a responsive-friendly `viewport` `` tag. */} + + + {/* + * Disables all search indexing for this application. If you are + * building an unauthenticated app, you probably want to remove + * this component, or update it to control how your site is indexed + * by search engines. + */} + + + ); +} diff --git a/packages/create/templates/app-graphql/foundation/html/Head.test.tsx b/packages/create/templates/app-graphql/foundation/html/Head.test.tsx deleted file mode 100644 index 038e62e5c..000000000 --- a/packages/create/templates/app-graphql/foundation/html/Head.test.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import {describe, it, expect} from 'vitest'; -import {Viewport, SearchRobots} from '@quilted/quilt/html'; - -import {renderApp} from '~/tests/render.ts'; - -import {Head} from './Head.tsx'; - -describe('', () => { - it('includes a responsive viewport tag', async () => { - const head = await renderApp(); - - expect(head).toContainReactComponent(Viewport, { - cover: true, - }); - }); - - it('prevents search robots from indexing the application', async () => { - const head = await renderApp(); - - expect(head).toContainReactComponent(SearchRobots, { - index: false, - follow: false, - }); - }); -}); diff --git a/packages/create/templates/app-graphql/foundation/html/Head.tsx b/packages/create/templates/app-graphql/foundation/html/Head.tsx deleted file mode 100644 index a52e54f21..000000000 --- a/packages/create/templates/app-graphql/foundation/html/Head.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import {Title, Viewport, Favicon, SearchRobots} from '@quilted/quilt/html'; - -// This component sets details of the HTML page. If you need to customize -// any of these details based on conditions like the active route, or some -// state about the user, you can move these components to wherever in your -// application you can read that state. -// -// @see https://github.com/lemonmade/quilt/blob/main/documentation/features/html.md -export function Head() { - return ( - <> - {/* Sets the default `` for this application. */} - <Title>App - - {/* - * Sets the default favicon used by the application. You can - * change this to a different emoji, make it `blank`, or pass - * a URL with the `source` prop. - */} - - - {/* Adds a responsive-friendly `viewport` `` tag. */} - - - {/* - * Disables all search indexing for this application. If you are - * building an unauthenticated app, you probably want to remove - * this component, or update it to control how your site is indexed - * by search engines. - */} - - - ); -} diff --git a/packages/create/templates/app-graphql/foundation/http.ts b/packages/create/templates/app-graphql/foundation/http.ts deleted file mode 100644 index 2e03263ac..000000000 --- a/packages/create/templates/app-graphql/foundation/http.ts +++ /dev/null @@ -1 +0,0 @@ -export {Headers} from './http/Headers.tsx'; diff --git a/packages/create/templates/app-graphql/foundation/http/Headers.test.tsx b/packages/create/templates/app-graphql/foundation/http/Headers.test.tsx deleted file mode 100644 index 362599d7a..000000000 --- a/packages/create/templates/app-graphql/foundation/http/Headers.test.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import {describe, it, expect} from 'vitest'; -import {CacheControl, ContentSecurityPolicy} from '@quilted/quilt/http'; - -import {renderApp} from '~/tests/render.ts'; - -import {Headers} from './Headers.tsx'; - -describe('', () => { - it('does not cache the response', async () => { - const headers = await renderApp(); - - expect(headers).toContainReactComponent(CacheControl, { - cache: false, - }); - }); - - it('adds a content security policy with a strict default policy', async () => { - const headers = await renderApp(); - - expect(headers).toContainReactComponent(ContentSecurityPolicy, { - defaultSources: ["'self'"], - }); - }); -}); diff --git a/packages/create/templates/app-graphql/server.tsx b/packages/create/templates/app-graphql/server.tsx index b2080cd4f..21d181c6b 100644 --- a/packages/create/templates/app-graphql/server.tsx +++ b/packages/create/templates/app-graphql/server.tsx @@ -21,15 +21,21 @@ router.post('/api/graphql', async (request) => { // For all GET requests, render our React application. router.get(async (request) => { - const [{App}, {performGraphQLOperation}, {renderToResponse}] = + const [{App}, {performGraphQLOperation}, {renderToResponse}, {QueryClient}] = await Promise.all([ import('./App.tsx'), import('./server/graphql.ts'), import('@quilted/quilt/server'), + import('@tanstack/react-query'), ]); const response = await renderToResponse( - , + , { request, assets, diff --git a/packages/create/templates/app-graphql/server/graphql.ts b/packages/create/templates/app-graphql/server/graphql.ts index 0ac9bf2ec..e7de9d0e0 100644 --- a/packages/create/templates/app-graphql/server/graphql.ts +++ b/packages/create/templates/app-graphql/server/graphql.ts @@ -30,7 +30,7 @@ const Query = createQueryResolver({ const schema = createGraphQLSchema(schemaSource, {Query, Person}); -export const performGraphQLOperation: GraphQLRun = +export const performGraphQLOperation: GraphQLRun<{}> = async function performGraphQLOperation(operation, options) { const result = await graphql({ schema, diff --git a/packages/create/templates/app-graphql/shared/graphql.ts b/packages/create/templates/app-graphql/shared/graphql.ts new file mode 100644 index 000000000..74bd9b096 --- /dev/null +++ b/packages/create/templates/app-graphql/shared/graphql.ts @@ -0,0 +1,11 @@ +import type {GraphQLFetch} from '@quilted/quilt/graphql'; +import type {QueryClient} from '@tanstack/react-query'; + +declare module '~/shared/context.ts' { + interface AppContext { + queryClient: QueryClient; + fetchGraphQL: GraphQLFetch<{}>; + } +} + +export {}; diff --git a/packages/create/templates/app-graphql/tests/render/render.tsx b/packages/create/templates/app-graphql/tests/render/render.tsx index 23b73f539..4f426dca8 100644 --- a/packages/create/templates/app-graphql/tests/render/render.tsx +++ b/packages/create/templates/app-graphql/tests/render/render.tsx @@ -24,7 +24,12 @@ export const renderApp = createRender< // authors on the `root.context` property. Context is used to share data between your // React tree and your test code, and is ideal for mocking out global context providers. context({router = new TestRouter(), graphql = new GraphQLController()}) { - return {router, graphql, queryClient: new QueryClient()}; + return { + router, + graphql, + fetchGraphQL: graphql.fetch, + queryClient: new QueryClient(), + }; }, // Render all of our app-wide context providers around each component under test. render(element, context, {locale = 'en'}) { diff --git a/packages/create/templates/app-trpc/App.tsx b/packages/create/templates/app-trpc/App.tsx index d0772c4ab..b2d600897 100644 --- a/packages/create/templates/app-trpc/App.tsx +++ b/packages/create/templates/app-trpc/App.tsx @@ -1,15 +1,11 @@ -import {useMemo, type PropsWithChildren} from 'react'; +import {type PropsWithChildren} from 'react'; -import {HTML} from '@quilted/quilt/html'; -import {Routing, useRoutes, useInitialUrl} from '@quilted/quilt/navigate'; +import {Routing, useRoutes} from '@quilted/quilt/navigate'; import {Localization, useLocaleFromEnvironment} from '@quilted/quilt/localize'; -import {httpBatchLink} from '@trpc/client'; -import {QueryClient} from '@tanstack/react-query'; import {ReactQueryContext} from '@quilted/react-query'; -import {Head} from './foundation/html.ts'; -import {Headers} from './foundation/http.ts'; +import {HTML} from './foundation/html.ts'; import {Frame} from './foundation/frame.ts'; import {Start} from './features/start.ts'; @@ -20,27 +16,21 @@ import { type AppContext as AppContextType, } from './shared/context.ts'; -export interface AppProps extends AppContextType {} +export interface AppProps { + context: AppContextType; +} // The root component for your application. You will typically render any // app-wide context in this component. -export function App(props: AppProps) { - const locale = useLocaleFromEnvironment() ?? 'en'; - +export function App({context}: AppProps) { return ( - - - - - - - - - - - - - + + + + + + + ); } @@ -55,29 +45,23 @@ function Routes() { } // This component renders any app-wide context. -function AppContext({children, ...context}: PropsWithChildren) { - const initialUrl = useInitialUrl(); - - const {queryClient, trpcClient} = useMemo(() => { - return { - queryClient: new QueryClient(), - trpcClient: trpc.createClient({ - links: [ - // We need to use an absolute URL so that queries will - // work during server-side rendering - httpBatchLink({url: new URL('/api', initialUrl).href}), - ], - }), - }; - }, [initialUrl]); +function AppContext({children, context}: PropsWithChildren) { + const locale = useLocaleFromEnvironment() ?? 'en'; return ( - - - - {children} - - - + + + + + + {children} + + + + + ); } diff --git a/packages/create/templates/app-trpc/browser.tsx b/packages/create/templates/app-trpc/browser.tsx index f7e5a8e3d..074c2ba62 100644 --- a/packages/create/templates/app-trpc/browser.tsx +++ b/packages/create/templates/app-trpc/browser.tsx @@ -1,15 +1,24 @@ import '@quilted/quilt/globals'; import {hydrateRoot} from 'react-dom/client'; import {httpBatchLink} from '@trpc/client'; +import {QueryClient} from '@tanstack/react-query'; +import {Browser, BrowserContext} from '@quilted/quilt/browser'; import {trpc} from '~/shared/trpc.ts'; import {App} from './App.tsx'; const element = document.querySelector('#app')!; +const browser = new Browser(); +const queryClient = new QueryClient(); const trpcClient = trpc.createClient({ links: [httpBatchLink({url: new URL('/api', window.location.href).href})], }); -hydrateRoot(element, ); +hydrateRoot( + element, + + + , +); diff --git a/packages/create/templates/app-trpc/foundation/html.ts b/packages/create/templates/app-trpc/foundation/html.ts index af771dbeb..4b307d8bc 100644 --- a/packages/create/templates/app-trpc/foundation/html.ts +++ b/packages/create/templates/app-trpc/foundation/html.ts @@ -1 +1 @@ -export {Head} from './html/Head.tsx'; +export {HTML} from './html/HTML.tsx'; diff --git a/packages/create/templates/app-trpc/foundation/html/HTML.test.tsx b/packages/create/templates/app-trpc/foundation/html/HTML.test.tsx new file mode 100644 index 000000000..76b4054fc --- /dev/null +++ b/packages/create/templates/app-trpc/foundation/html/HTML.test.tsx @@ -0,0 +1,46 @@ +import {describe, it, expect} from 'vitest'; +import { + CacheControl, + ContentSecurityPolicy, + SearchRobots, + Viewport, +} from '@quilted/quilt/server'; + +import {renderApp} from '~/tests/render.ts'; + +import {HTML} from './HTML.tsx'; + +describe('', () => { + it('includes a responsive viewport tag', async () => { + const head = await renderApp(); + + expect(head).toContainReactComponent(Viewport, { + cover: true, + }); + }); + + it('prevents search robots from indexing the application', async () => { + const head = await renderApp(); + + expect(head).toContainReactComponent(SearchRobots, { + index: false, + follow: false, + }); + }); + + it('does not cache the response', async () => { + const headers = await renderApp(); + + expect(headers).toContainReactComponent(CacheControl, { + cache: false, + }); + }); + + it('adds a content security policy with a strict default policy', async () => { + const headers = await renderApp(); + + expect(headers).toContainReactComponent(ContentSecurityPolicy, { + defaultSources: ["'self'"], + }); + }); +}); diff --git a/packages/create/templates/app-graphql/foundation/http/Headers.tsx b/packages/create/templates/app-trpc/foundation/html/HTML.tsx similarity index 75% rename from packages/create/templates/app-graphql/foundation/http/Headers.tsx rename to packages/create/templates/app-trpc/foundation/html/HTML.tsx index 5c744f438..48331a591 100644 --- a/packages/create/templates/app-graphql/foundation/http/Headers.tsx +++ b/packages/create/templates/app-trpc/foundation/html/HTML.tsx @@ -1,21 +1,34 @@ -import Env from '@quilted/quilt/env'; -import {useInitialUrl} from '@quilted/quilt/navigate'; +import type {PropsWithChildren} from 'react'; +import {Title, Favicon, useBrowserRequest} from '@quilted/quilt/browser'; import { CacheControl, ResponseHeader, ContentSecurityPolicy, PermissionsPolicy, + SearchRobots, StrictTransportSecurity, -} from '@quilted/quilt/http'; + Viewport, +} from '@quilted/quilt/server'; -// This component sets details on the HTTP response for all HTML server-rendering -// requests. If you need to customize any of these details based on conditions like -// the active route, or some state about the user, you can move these components to -// wherever in your application you can read that state. +// This component sets details of the HTML page. If you need to customize +// any of these details based on conditions like the active route, or some +// state about the user, you can move these components to wherever in your +// application you can read that state. // -// @see https://github.com/lemonmade/quilt/blob/main/documentation/features/http.md -export function Headers() { - const isHttps = useInitialUrl().protocol === 'https:'; +// @see https://github.com/lemonmade/quilt/blob/main/documentation/features/html.md +export function HTML({children}: PropsWithChildren) { + return ( + <> + + + {children} + + ); +} + +function Headers() { + const {url} = useBrowserRequest(); + const isHttps = new URL(url).protocol === 'https:'; return ( <> @@ -116,3 +129,30 @@ export function Headers() { ); } + +function Head() { + return ( + <> + {/* Sets the default `` for this application. */} + <Title>App + + {/* + * Sets the default favicon used by the application. You can + * change this to a different emoji, make it `blank`, or pass + * a URL with the `source` prop. + */} + + + {/* Adds a responsive-friendly `viewport` `` tag. */} + + + {/* + * Disables all search indexing for this application. If you are + * building an unauthenticated app, you probably want to remove + * this component, or update it to control how your site is indexed + * by search engines. + */} + + + ); +} diff --git a/packages/create/templates/app-trpc/foundation/html/Head.test.tsx b/packages/create/templates/app-trpc/foundation/html/Head.test.tsx deleted file mode 100644 index 038e62e5c..000000000 --- a/packages/create/templates/app-trpc/foundation/html/Head.test.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import {describe, it, expect} from 'vitest'; -import {Viewport, SearchRobots} from '@quilted/quilt/html'; - -import {renderApp} from '~/tests/render.ts'; - -import {Head} from './Head.tsx'; - -describe('', () => { - it('includes a responsive viewport tag', async () => { - const head = await renderApp(); - - expect(head).toContainReactComponent(Viewport, { - cover: true, - }); - }); - - it('prevents search robots from indexing the application', async () => { - const head = await renderApp(); - - expect(head).toContainReactComponent(SearchRobots, { - index: false, - follow: false, - }); - }); -}); diff --git a/packages/create/templates/app-trpc/foundation/html/Head.tsx b/packages/create/templates/app-trpc/foundation/html/Head.tsx deleted file mode 100644 index a52e54f21..000000000 --- a/packages/create/templates/app-trpc/foundation/html/Head.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import {Title, Viewport, Favicon, SearchRobots} from '@quilted/quilt/html'; - -// This component sets details of the HTML page. If you need to customize -// any of these details based on conditions like the active route, or some -// state about the user, you can move these components to wherever in your -// application you can read that state. -// -// @see https://github.com/lemonmade/quilt/blob/main/documentation/features/html.md -export function Head() { - return ( - <> - {/* Sets the default `` for this application. */} - <Title>App - - {/* - * Sets the default favicon used by the application. You can - * change this to a different emoji, make it `blank`, or pass - * a URL with the `source` prop. - */} - - - {/* Adds a responsive-friendly `viewport` `` tag. */} - - - {/* - * Disables all search indexing for this application. If you are - * building an unauthenticated app, you probably want to remove - * this component, or update it to control how your site is indexed - * by search engines. - */} - - - ); -} diff --git a/packages/create/templates/app-trpc/foundation/http.ts b/packages/create/templates/app-trpc/foundation/http.ts deleted file mode 100644 index 2e03263ac..000000000 --- a/packages/create/templates/app-trpc/foundation/http.ts +++ /dev/null @@ -1 +0,0 @@ -export {Headers} from './http/Headers.tsx'; diff --git a/packages/create/templates/app-trpc/foundation/http/Headers.test.tsx b/packages/create/templates/app-trpc/foundation/http/Headers.test.tsx deleted file mode 100644 index 362599d7a..000000000 --- a/packages/create/templates/app-trpc/foundation/http/Headers.test.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import {describe, it, expect} from 'vitest'; -import {CacheControl, ContentSecurityPolicy} from '@quilted/quilt/http'; - -import {renderApp} from '~/tests/render.ts'; - -import {Headers} from './Headers.tsx'; - -describe('', () => { - it('does not cache the response', async () => { - const headers = await renderApp(); - - expect(headers).toContainReactComponent(CacheControl, { - cache: false, - }); - }); - - it('adds a content security policy with a strict default policy', async () => { - const headers = await renderApp(); - - expect(headers).toContainReactComponent(ContentSecurityPolicy, { - defaultSources: ["'self'"], - }); - }); -}); diff --git a/packages/create/templates/app-trpc/server.tsx b/packages/create/templates/app-trpc/server.tsx index 57c78bf9b..79ef61b7f 100644 --- a/packages/create/templates/app-trpc/server.tsx +++ b/packages/create/templates/app-trpc/server.tsx @@ -25,13 +25,19 @@ router.any( // For all GET requests, render our React application. router.get(async (request) => { - const [{App}, {renderToResponse}] = await Promise.all([ + const [{App}, {renderToResponse}, {QueryClient}] = await Promise.all([ import('./App.tsx'), import('@quilted/quilt/server'), + import('@tanstack/react-query'), ]); const response = await renderToResponse( - , + , { request, assets, diff --git a/packages/create/templates/app-trpc/shared/trpc.ts b/packages/create/templates/app-trpc/shared/trpc.ts index 2810ab129..879b01dff 100644 --- a/packages/create/templates/app-trpc/shared/trpc.ts +++ b/packages/create/templates/app-trpc/shared/trpc.ts @@ -1,10 +1,11 @@ -import {type TRPCClient} from '@trpc/client'; +import type {TRPCClient} from '@trpc/client'; import {createTRPCReact, type CreateTRPCReact} from '@trpc/react-query'; +import type {QueryClient} from '@tanstack/react-query'; // Get access to our app’s router type signature, which will // provide strong typing on the queries and mutations we can // perform. -import {type AppRouter} from '../trpc.ts'; +import type {AppRouter} from '../trpc.ts'; export const trpc: CreateTRPCReact = createTRPCReact(); @@ -12,5 +13,6 @@ export const trpc: CreateTRPCReact = declare module '~/shared/context.ts' { interface AppContext { trpc: TRPCClient; + queryClient: QueryClient; } } diff --git a/packages/graphql/source/fetch/fetch.ts b/packages/graphql/source/fetch/fetch.ts index 5622b132e..a400ad7a1 100644 --- a/packages/graphql/source/fetch/fetch.ts +++ b/packages/graphql/source/fetch/fetch.ts @@ -11,7 +11,7 @@ import {GraphQLFetchRequest} from './request.ts'; /** * A function that can fetch GraphQL queries and mutations over HTTP. */ -export interface GraphQLFetch> { +export interface GraphQLFetch { , Variables = Record>( operation: GraphQLAnyOperation, options?: GraphQLFetchOptions, diff --git a/packages/graphql/source/types.ts b/packages/graphql/source/types.ts index 1aece686f..b1e22caf6 100644 --- a/packages/graphql/source/types.ts +++ b/packages/graphql/source/types.ts @@ -120,10 +120,7 @@ export type GraphQLResult< * type-safe access to this additional context, you can extend the `GraphQLFetchContext` * type from this library. */ -export interface GraphQLRun< - Context = Record, - Extensions = Record, -> { +export interface GraphQLRun { , Variables = Record>( operation: GraphQLAnyOperation, options?: GraphQLOperationOptions, diff --git a/packages/quilt/package.json b/packages/quilt/package.json index b921428dd..798892490 100644 --- a/packages/quilt/package.json +++ b/packages/quilt/package.json @@ -31,24 +31,18 @@ "quilt:esnext": "./build/esnext/async.esnext", "import": "./build/esm/async.mjs" }, + "./browser": { + "types": "./build/typescript/browser.d.ts", + "quilt:source": "./source/browser.ts", + "quilt:esnext": "./build/esnext/browser.esnext", + "import": "./build/esm/browser.mjs" + }, "./globals": { "types": "./build/typescript/globals.d.ts", "quilt:source": "./source/globals.ts", "quilt:esnext": "./build/esnext/globals.esnext", "import": "./build/esm/globals.mjs" }, - "./html": { - "types": "./build/typescript/html.d.ts", - "quilt:source": "./source/html.ts", - "quilt:esnext": "./build/esnext/html.esnext", - "import": "./build/esm/html.mjs" - }, - "./http": { - "types": "./build/typescript/http.d.ts", - "quilt:source": "./source/http.ts", - "quilt:esnext": "./build/esnext/http.esnext", - "import": "./build/esm/http.mjs" - }, "./events": { "types": "./build/typescript/events.d.ts", "quilt:source": "./source/events.ts", @@ -161,8 +155,8 @@ "async": [ "./build/typescript/async.d.ts" ], - "html": [ - "./build/typescript/html.d.ts" + "browser": [ + "./build/typescript/browser.d.ts" ], "globals": [ "./build/typescript/globals.d.ts" @@ -170,9 +164,6 @@ "env": [ "./build/typescript/env.d.ts" ], - "http": [ - "./build/typescript/http.d.ts" - ], "events": [ "./build/typescript/localize.d.ts" ], @@ -221,9 +212,15 @@ } }, "sideEffects": [ + "./source/browser.ts", + "./build/esnext/browser.esnext", + "./build/esm/browser.mjs", "./source/globals.ts", "./build/esnext/globals.esnext", - "./build/esm/globals.mjs" + "./build/esm/globals.mjs", + "./source/signals.ts", + "./build/esnext/signals.esnext", + "./build/esm/signals.mjs" ], "scripts": { "build": "rollup --config configuration/rollup.config.js" @@ -234,17 +231,14 @@ "@quilted/events": "workspace:^2.0.0", "@quilted/graphql": "workspace:^3.0.2", "@quilted/react": "workspace:^18.2.0", - "@quilted/react-assets": "workspace:^0.1.1", "@quilted/react-async": "workspace:^0.4.1", + "@quilted/react-browser": "workspace:^0.0.0", "@quilted/react-dom": "workspace:^18.2.0", "@quilted/react-graphql": "workspace:^0.5.0", - "@quilted/react-html": "workspace:^0.4.0", - "@quilted/react-http": "workspace:^0.4.0", "@quilted/react-idle": "workspace:^0.4.0", "@quilted/react-localize": "workspace:^0.2.0", "@quilted/react-performance": "workspace:^0.2.0", "@quilted/react-router": "workspace:^0.4.0", - "@quilted/react-server-render": "workspace:^0.4.0", "@quilted/react-signals": "workspace:^0.2.4", "@quilted/react-testing": "workspace:^0.6.0", "@quilted/react-utilities": "workspace:^0.2.0", @@ -252,6 +246,7 @@ "@quilted/request-router": "workspace:^0.2.0", "@quilted/signals": "workspace:^0.2.0", "@quilted/threads": "workspace:^2.0.0", + "preact-render-to-string": "^6.4.0", "jest-matcher-utils": "^29.0.0" }, "peerDependencies": { diff --git a/packages/quilt/source/assets.ts b/packages/quilt/source/assets.ts index e7e50b687..e2be76db5 100644 --- a/packages/quilt/source/assets.ts +++ b/packages/quilt/source/assets.ts @@ -19,7 +19,6 @@ export type { AssetsBuildManifest, AssetsBuildManifestEntry, } from '@quilted/assets'; -export {useAssetsCacheKey, useModuleAssets} from '@quilted/react-assets'; declare module '@quilted/assets' { interface AssetsCacheKey { diff --git a/packages/quilt/source/browser.ts b/packages/quilt/source/browser.ts new file mode 100644 index 000000000..00952e9b4 --- /dev/null +++ b/packages/quilt/source/browser.ts @@ -0,0 +1,5 @@ +// Browser utilities depend on Signals, import to make sure we get the Preact +// hook for signals installed. +import './signals.ts'; + +export * from '@quilted/react-browser'; diff --git a/packages/quilt/source/html.ts b/packages/quilt/source/html.ts deleted file mode 100644 index ee0c2da83..000000000 --- a/packages/quilt/source/html.ts +++ /dev/null @@ -1,40 +0,0 @@ -export { - Alternate, - BodyAttributes, - HeadScript, - HeadStyle, - HTMLAttributes, - Link, - Meta, - SearchRobots, - Serialize, - ThemeColor, - Title, - Viewport, - Favicon, - useAlternateUrl, - useSerialized, - useBodyAttributes, - useHeadScript, - useHeadStyle, - useHTMLAttributes, - useFavicon, - useLink, - useLocale, - useMeta, - useSearchRobots, - useThemeColor, - useTitle, - useViewport, - useHTMLUpdater, - getSerialized, -} from '@quilted/react-html'; -export type {Serializable} from '@quilted/react-html'; -export { - useCookie, - useCookies, - type Cookies, - type CookieOptions, -} from '@quilted/react-http'; - -export {HTML} from './html/HTML.tsx'; diff --git a/packages/quilt/source/html/HTML.tsx b/packages/quilt/source/html/HTML.tsx deleted file mode 100644 index f9f50cba3..000000000 --- a/packages/quilt/source/html/HTML.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import type {PropsWithChildren} from 'react'; - -import {HttpContext, CookieContext} from '@quilted/react-http'; -import {useHTMLUpdater} from '@quilted/react-html'; - -export interface HTMLProps {} - -export function HTML({children}: PropsWithChildren) { - return ( - - - - {children} - - - ); -} - -function HTMLUpdater() { - useHTMLUpdater(); - return null; -} diff --git a/packages/quilt/source/http.ts b/packages/quilt/source/http.ts deleted file mode 100644 index e81e940b1..000000000 --- a/packages/quilt/source/http.ts +++ /dev/null @@ -1,37 +0,0 @@ -export { - CacheControl, - ContentSecurityPolicy, - HttpMethod, - ResponseType, - StatusCode, - useCookie, - useCookies, - useCacheControl, - useContentSecurityPolicy, - useCrossOriginEmbedderPolicy, - useCrossOriginOpenerPolicy, - useCrossOriginResourcePolicy, - usePermissionsPolicy, - useRequestHeader, - useRequestHeaders, - useResponseCookie, - useDeleteResponseCookie, - useResponseHeader, - useResponseRedirect, - useResponseStatus, - useStrictTransportSecurity, - CookieContext, - HttpContext, - NotFound, - PermissionsPolicy, - ResponseCookie, - ResponseHeader, - ResponseStatus, - StrictTransportSecurity, - ContentSecurityPolicyDirective, - ContentSecurityPolicySandboxAllow, - ContentSecurityPolicySpecialSource, - CrossOriginEmbedderPolicy, - CrossOriginOpenerPolicy, - CrossOriginResourcePolicy, -} from '@quilted/react-http'; diff --git a/packages/quilt/source/navigate.ts b/packages/quilt/source/navigate.ts index d8ef76b2e..1a4b5822f 100644 --- a/packages/quilt/source/navigate.ts +++ b/packages/quilt/source/navigate.ts @@ -5,7 +5,7 @@ export { RoutePreloading, NavigationBlock, useCurrentUrl, - useInitialUrl, + useInitialURL, useRouter, useRoutes, useRouteMatch, diff --git a/packages/quilt/source/routing.ts b/packages/quilt/source/routing.ts deleted file mode 100644 index d8ef76b2e..000000000 --- a/packages/quilt/source/routing.ts +++ /dev/null @@ -1,24 +0,0 @@ -export { - Link, - Redirect, - Routing, - RoutePreloading, - NavigationBlock, - useCurrentUrl, - useInitialUrl, - useRouter, - useRoutes, - useRouteMatch, - useRouteMatchDetails, - useNavigate, - useRedirect, - useNavigationBlock, - useScrollRestoration, - useRouteChangeFocusRef, -} from '@quilted/react-router'; -export type { - Router, - NavigateTo, - Routes, - RouteDefinition, -} from '@quilted/react-router'; diff --git a/packages/quilt/source/server.ts b/packages/quilt/source/server.ts index f4b3c4530..d992faca1 100644 --- a/packages/quilt/source/server.ts +++ b/packages/quilt/source/server.ts @@ -1,15 +1,4 @@ -export { - Head, - Serialize, - HTMLManager, - HTMLContext, - SERVER_ACTION_ID as HTML_SERVER_ACTION_ID, -} from '@quilted/react-html/server'; -export { - extract, - ServerRenderManager, - ServerRenderManagerContext, -} from '@quilted/react-server-render/server'; +export * from '@quilted/react-browser/server'; export { styleAssetAttributes, @@ -29,28 +18,7 @@ export type { AssetsBuildManifest, AssetsBuildManifestEntry, } from '@quilted/assets'; -export { - useAssetsCacheKey, - useModuleAssets, - AssetsContext, - AssetsManager, - SERVER_ACTION_ID as ASSETS_SERVER_ACTION_ID, -} from '@quilted/react-assets/server'; -export type {HttpState} from '@quilted/react-http/server'; export {parseAcceptLanguageHeader} from '@quilted/react-localize'; export {createRequestRouterLocalization} from '@quilted/react-localize/request-router'; -export { - ServerAction, - useServerAction, - useServerContext, -} from '@quilted/react-server-render'; -export type { - ServerActionKind, - ServerActionOptions, - ServerActionPerform, - ServerRenderPass, - ServerRenderRequestContext, -} from '@quilted/react-server-render'; -export {ServerContext} from './server/ServerContext.tsx'; export {renderToResponse} from './server/request-router.tsx'; diff --git a/packages/quilt/source/server/ServerContext.tsx b/packages/quilt/source/server/ServerContext.tsx index 0c80e863d..6f1cb4f59 100644 --- a/packages/quilt/source/server/ServerContext.tsx +++ b/packages/quilt/source/server/ServerContext.tsx @@ -1,55 +1,34 @@ import type {PropsWithChildren} from 'react'; -import {AssetsContext, type AssetsManager} from '@quilted/react-assets/server'; -import {InitialUrlContext} from '@quilted/react-router'; -import {HTMLContext, type HTMLManager} from '@quilted/react-html/server'; -import {HttpServerContext, type HttpManager} from '@quilted/react-http/server'; +import {InitialURLContext} from '@quilted/react-router'; +import { + BrowserDetailsContext, + type BrowserDetails, +} from '@quilted/react-browser/server'; interface Props { - url?: string | URL; - html?: HTMLManager; - http?: HttpManager; - assets?: AssetsManager; + browser?: BrowserDetails; } -export function ServerContext({ - url, - html, - http, - assets, - children, -}: PropsWithChildren) { - const normalizedUrl = typeof url === 'string' ? new URL(url) : url; +export function ServerContext({browser, children}: PropsWithChildren) { + const requestURL = browser?.request.url; + const initialURL = requestURL && new URL(requestURL); - const withInitialURL = normalizedUrl ? ( - + const withInitialURL = initialURL ? ( + {children} - + ) : ( children ); - const withHTML = html ? ( - {withInitialURL} + const withBrowser = browser ? ( + + {withInitialURL} + ) : ( withInitialURL ); - const withHTTPServer = http ? ( - - {withHTML} - - ) : ( - withHTML - ); - - const withAssets = assets ? ( - - {withHTTPServer} - - ) : ( - withHTTPServer - ); - - return withAssets; + return withBrowser; } diff --git a/packages/quilt/source/server/request-router.tsx b/packages/quilt/source/server/request-router.tsx index 2a9bb86ef..d1e701a6f 100644 --- a/packages/quilt/source/server/request-router.tsx +++ b/packages/quilt/source/server/request-router.tsx @@ -1,5 +1,8 @@ import {isValidElement, type ReactElement} from 'react'; -import {renderToStaticMarkup} from 'react-dom/server'; +import { + renderToStaticMarkup, + renderToStringAsync, +} from 'preact-render-to-string'; import { styleAssetPreloadAttributes, @@ -8,17 +11,13 @@ import { type BrowserAssets, type BrowserAssetsEntry, } from '@quilted/assets'; -import {AssetsManager} from '@quilted/react-assets/server'; -import {HttpManager} from '@quilted/react-http/server'; import { - Head, - Script, - ScriptPreload, - Style, - StylePreload, - HTMLManager, -} from '@quilted/react-html/server'; -import {extract} from '@quilted/react-server-render/server'; + BrowserResponse, + ScriptAsset, + ScriptAssetPreload, + StyleAsset, + StyleAssetPreload, +} from '@quilted/react-browser/server'; import {HTMLResponse, RedirectResponse} from '@quilted/request-router'; @@ -28,37 +27,38 @@ export interface RenderHTMLFunction { ( content: ReadableStream, context: { - readonly manager: HTMLManager; - readonly headers: Headers; + readonly response: BrowserResponse; readonly assets?: BrowserAssetsEntry; readonly preloadAssets?: BrowserAssetsEntry; }, ): ReadableStream | string | Promise | string>; } -export interface RenderOptions { +export interface RenderOptions { readonly request: Request; + readonly status?: number; readonly stream?: 'headers' | false; readonly headers?: HeadersInit; - readonly assets?: BrowserAssets; - readonly cacheKey?: CacheKey; + readonly assets?: BrowserAssets; + readonly cacheKey?: Partial; + readonly serializations?: Iterable<[string, unknown]>; readonly renderHTML?: boolean | 'fragment' | 'document' | RenderHTMLFunction; waitUntil?(promise: Promise): void; } -export async function renderToResponse( +export async function renderToResponse( element: ReactElement, - options: RenderOptions, + options: RenderOptions, ): Promise; -export async function renderToResponse( - options: RenderOptions, +export async function renderToResponse( + options: RenderOptions, ): Promise; -export async function renderToResponse( - optionsOrElement: ReactElement | RenderOptions, - definitelyOptions?: RenderOptions, +export async function renderToResponse( + optionsOrElement: ReactElement | RenderOptions, + definitelyOptions?: RenderOptions, ) { let element: ReactElement | undefined; - let options: RenderOptions; + let options: RenderOptions; if (isValidElement(optionsOrElement)) { element = optionsOrElement; @@ -70,67 +70,35 @@ export async function renderToResponse( const { request, assets, + status: explicitStatus, cacheKey: explicitCacheKey, headers: explicitHeaders, + serializations: explicitSerializations, waitUntil = noop, stream: shouldStream = false, renderHTML = true, } = options; - const baseUrl = (request as any).URL ?? new URL(request.url); + const baseURL = (request as any).URL ?? new URL(request.url); const cacheKey = explicitCacheKey ?? - (((await assets?.cacheKey?.(request)) ?? {}) as CacheKey); + (((await assets?.cacheKey?.(request)) ?? {}) as AssetsCacheKey); - const html = new HTMLManager(); - const http = new HttpManager({headers: request.headers}); - const assetsManager = new AssetsManager({cacheKey}); + const browserResponse = new BrowserResponse({ + request, + cacheKey, + status: explicitStatus, + headers: new Headers(explicitHeaders), + serializations: explicitSerializations, + }); - let responseStatus = 200; let appStream: ReadableStream | undefined; - const headers = new Headers(explicitHeaders); if (shouldStream === false && element != null) { - const rendered = await extract(element, { - decorate(element) { - return ( - - {element} - - ); - }, - }); - - const {headers: appHeaders, statusCode = 200, redirectUrl} = http.state; - - const hasSetCookieHeader = typeof appHeaders.getSetCookie === 'function'; - - if (hasSetCookieHeader) { - for (const cookie of appHeaders.getSetCookie()) { - headers.append('Set-Cookie', cookie); - } - } - - for (const [header, value] of appHeaders.entries()) { - if (hasSetCookieHeader && header.toLowerCase() === 'set-cookie') continue; - headers.set(header, value); - } - - if (redirectUrl) { - return new RedirectResponse(redirectUrl, { - status: statusCode as 301, - headers: headers, - request, - }); - } - - responseStatus = statusCode; + const rendered = await renderToStringAsync( + {element}, + ); const appTransformStream = new TransformStream(); const appWriter = appTransformStream.writable.getWriter(); @@ -148,20 +116,9 @@ export async function renderToResponse( const appWriter = appTransformStream.writable.getWriter(); if (element != null) { - const rendered = await extract(element, { - decorate(element) { - return ( - - {element} - - ); - }, - }); + const rendered = await renderToStringAsync( + {element}, + ); appWriter.write(rendered); } @@ -175,8 +132,8 @@ export async function renderToResponse( const body = await renderToHTMLBody(appStream); return new HTMLResponse(body, { - status: responseStatus, - headers, + status: browserResponse.status.value, + headers: browserResponse.headers, }); async function renderToHTMLBody( @@ -185,23 +142,23 @@ export async function renderToResponse( const [synchronousAssets, preloadAssets] = await Promise.all([ assets?.entry({ cacheKey, - modules: assetsManager.usedModules({timing: 'load'}), + modules: browserResponse.assets.get({timing: 'load'}), }), - assets?.modules(assetsManager.usedModules({timing: 'preload'}), { + assets?.modules(browserResponse.assets.get({timing: 'preload'}), { cacheKey, }), ]); if (synchronousAssets) { for (const style of synchronousAssets.styles) { - headers.append( + browserResponse.headers.append( 'Link', preloadHeader(styleAssetPreloadAttributes(style)), ); } for (const script of synchronousAssets.scripts) { - headers.append( + browserResponse.headers.append( 'Link', preloadHeader(scriptAssetPreloadAttributes(script)), ); @@ -210,8 +167,7 @@ export async function renderToResponse( if (typeof renderHTML === 'function') { const body = await renderHTML(content, { - manager: html, - headers, + response: browserResponse, assets: synchronousAssets, preloadAssets, }); @@ -229,34 +185,45 @@ export async function renderToResponse( writer.write(``); - const {htmlAttributes, bodyAttributes, ...headProps} = html.state; const htmlContent = renderToStaticMarkup( - + - + {browserResponse.title.value && ( + {browserResponse.title.value} + )} + {browserResponse.links.value.map((link, index) => ( + + ))} + {browserResponse.metas.value.map((meta, index) => ( + + ))} {synchronousAssets?.scripts.map((script) => ( -