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. */}
+ 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. */}
- 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. */}
+ 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. */}
- 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. */}
+ 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. */}
- 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) => (
-
+
))}
{synchronousAssets?.styles.map((style) => (
-
+
))}
{preloadAssets?.styles.map((style) => (
-
))}
{preloadAssets?.scripts.map((script) => (
-
))}
,
@@ -283,9 +250,9 @@ export async function renderToResponse(
const [newSynchronousAssets, newPreloadAssets] = 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,
}),
]);
@@ -304,23 +271,27 @@ export async function renderToResponse(
const additionalAssetsContent = renderToStaticMarkup(
<>
{diffedSynchronousAssets.scripts.map((script) => (
-
+
))}
{diffedSynchronousAssets.styles.map((style) => (
-
+
))}
{diffedPreloadAssets.styles.map((style) => (
-
))}
{diffedPreloadAssets.scripts.map((script) => (
-
))}
>,
diff --git a/packages/quilt/tsconfig.json b/packages/quilt/tsconfig.json
index c4b04072a..18f3293d0 100644
--- a/packages/quilt/tsconfig.json
+++ b/packages/quilt/tsconfig.json
@@ -12,17 +12,14 @@
{"path": "../events"},
{"path": "../graphql"},
{"path": "../react"},
- {"path": "../react-assets"},
{"path": "../react-async"},
+ {"path": "../react-browser"},
{"path": "../react-dom"},
{"path": "../react-graphql"},
- {"path": "../react-html"},
- {"path": "../react-http"},
{"path": "../react-idle"},
{"path": "../react-localize"},
{"path": "../react-performance"},
{"path": "../react-router"},
- {"path": "../react-server-render"},
{"path": "../react-signals"},
{"path": "../react-testing"},
{"path": "../react-utilities"},
diff --git a/packages/react-assets/CHANGELOG.md b/packages/react-assets/CHANGELOG.md
deleted file mode 100644
index c6b307586..000000000
--- a/packages/react-assets/CHANGELOG.md
+++ /dev/null
@@ -1,68 +0,0 @@
-# @quilted/react-assets
-
-## 0.1.1
-
-### Patch Changes
-
-- [#699](https://github.com/lemonmade/quilt/pull/699) [`8335c47`](https://github.com/lemonmade/quilt/commit/8335c47fa1896ad65d5cd218fe068f22627815d9) Thanks [@lemonmade](https://github.com/lemonmade)! - Update async APIs
-
-## 0.1.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/assets@0.1.0
- - @quilted/react-server-render@0.4.0
-
-## 0.0.6
-
-### Patch Changes
-
-- Updated dependencies [[`750dd6b9`](https://github.com/lemonmade/quilt/commit/750dd6b9cb6a18648cc793f57579fb0b64cb23bc)]:
- - @quilted/assets@0.0.5
-
-## 0.0.5
-
-### Patch Changes
-
-- Updated dependencies [[`bc849bc7`](https://github.com/lemonmade/quilt/commit/bc849bc740318936656162fde851b784ed6ef78f)]:
- - @quilted/assets@0.0.4
-
-## 0.0.4
-
-### 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
-
-## 0.0.3
-
-### 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/assets@0.0.3
-
-## 0.0.2
-
-### Patch Changes
-
-- [#532](https://github.com/lemonmade/quilt/pull/532) [`70b042d2`](https://github.com/lemonmade/quilt/commit/70b042d256579ab88e4ac65b2f869f143332de56) Thanks [@lemonmade](https://github.com/lemonmade)! - Move asset manifest code into asset packages
-
-- Updated dependencies [[`70b042d2`](https://github.com/lemonmade/quilt/commit/70b042d256579ab88e4ac65b2f869f143332de56)]:
- - @quilted/assets@0.0.2
-
-## 0.0.1
-
-### Patch Changes
-
-- [#527](https://github.com/lemonmade/quilt/pull/527) [`a255c7c2`](https://github.com/lemonmade/quilt/commit/a255c7c284391b2c3157fffed5a5feb576cd45ac) Thanks [@lemonmade](https://github.com/lemonmade)! - Improve asset manifests
-
-- Updated dependencies [[`a255c7c2`](https://github.com/lemonmade/quilt/commit/a255c7c284391b2c3157fffed5a5feb576cd45ac)]:
- - @quilted/assets@0.0.1
diff --git a/packages/react-assets/README.md b/packages/react-assets/README.md
deleted file mode 100644
index ebf8cc89d..000000000
--- a/packages/react-assets/README.md
+++ /dev/null
@@ -1 +0,0 @@
-# `@quilted/react-assets`
diff --git a/packages/react-assets/source/constants.ts b/packages/react-assets/source/constants.ts
deleted file mode 100644
index 3960287dd..000000000
--- a/packages/react-assets/source/constants.ts
+++ /dev/null
@@ -1 +0,0 @@
-export const SERVER_ACTION_ID = Symbol('assets');
diff --git a/packages/react-assets/source/context.ts b/packages/react-assets/source/context.ts
deleted file mode 100644
index 2a731912b..000000000
--- a/packages/react-assets/source/context.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-import {createContext} from 'react';
-import {AssetsManager} from './manager.ts';
-
-export const AssetsContext = createContext(new AssetsManager());
diff --git a/packages/react-assets/source/hooks.ts b/packages/react-assets/source/hooks.ts
deleted file mode 100644
index 430cad74f..000000000
--- a/packages/react-assets/source/hooks.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import {useContext} from 'react';
-import type {AssetsCacheKey, AssetLoadTiming} from '@quilted/assets';
-import {useServerAction} from '@quilted/react-server-render';
-
-import {AssetsContext} from './context.ts';
-
-export function useAssetsCacheKey(cacheKey: Partial) {
- const assets = useContext(AssetsContext);
-
- useServerAction(() => {
- assets.updateCacheKey(cacheKey);
- }, assets.serverAction);
-}
-
-export function useModuleAssets(
- id?: string,
- {scripts, styles}: {styles?: AssetLoadTiming; scripts?: AssetLoadTiming} = {},
-) {
- const assets = useContext(AssetsContext);
-
- useServerAction(() => {
- if (id) {
- assets.useModule(id, {scripts, styles});
- }
- }, assets.serverAction);
-}
diff --git a/packages/react-assets/source/index.ts b/packages/react-assets/source/index.ts
deleted file mode 100644
index cdd1cd16a..000000000
--- a/packages/react-assets/source/index.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-export type {AssetLoadTiming} from '@quilted/assets';
-
-export {AssetsContext} from './context.ts';
-export {useAssetsCacheKey, useModuleAssets} from './hooks.ts';
-export {AssetsManager} from './manager.ts';
diff --git a/packages/react-assets/source/manager.ts b/packages/react-assets/source/manager.ts
deleted file mode 100644
index 65e08e5e5..000000000
--- a/packages/react-assets/source/manager.ts
+++ /dev/null
@@ -1,103 +0,0 @@
-import type {
- AssetsCacheKey,
- AssetLoadTiming,
- BrowserAssetModuleSelector,
-} from '@quilted/assets';
-import type {ServerActionKind} from '@quilted/react-server-render';
-import {SERVER_ACTION_ID} from './constants.ts';
-
-const ASSET_TIMING_PRIORITY: AssetLoadTiming[] = ['never', 'preload', 'load'];
-
-const PRIORITY_BY_TIMING = new Map(
- ASSET_TIMING_PRIORITY.map((value, index) => [value, index]),
-);
-
-export class AssetsManager {
- readonly serverAction: ServerActionKind = {
- id: SERVER_ACTION_ID,
- betweenEachPass: () => {
- this.cacheKeyUpdates = {};
- this.usedModulesWithTiming.clear();
- },
- };
-
- private usedModulesWithTiming = new Map<
- string,
- {
- styles: AssetLoadTiming;
- scripts: AssetLoadTiming;
- }
- >();
- private cacheKeyUpdates: Partial = {};
- private readonly initialCacheKey: Partial;
-
- constructor({cacheKey}: {cacheKey?: Partial} = {}) {
- this.initialCacheKey = cacheKey ?? {};
- }
-
- get cacheKey() {
- return {...this.initialCacheKey, ...this.cacheKeyUpdates};
- }
-
- updateCacheKey(cacheKey: Partial) {
- Object.assign(this.cacheKeyUpdates, cacheKey);
- }
-
- useModule(
- 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),
- });
- }
- }
-
- usedModules({
- 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/react-assets/source/server.ts b/packages/react-assets/source/server.ts
deleted file mode 100644
index 3ea093388..000000000
--- a/packages/react-assets/source/server.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-export {SERVER_ACTION_ID} from './constants.ts';
-export {AssetsContext} from './context.ts';
-export {useAssetsCacheKey, useModuleAssets} from './hooks.ts';
-export {AssetsManager} from './manager.ts';
diff --git a/packages/react-assets/tsconfig.json b/packages/react-assets/tsconfig.json
deleted file mode 100644
index c630b4bb9..000000000
--- a/packages/react-assets/tsconfig.json
+++ /dev/null
@@ -1,10 +0,0 @@
-{
- "extends": "@quilted/craft/typescript/project.json",
- "compilerOptions": {
- "rootDir": "source",
- "outDir": "build/typescript"
- },
- "include": ["source"],
- "exclude": [],
- "references": [{"path": "../assets"}, {"path": "../react-server-render"}]
-}
diff --git a/packages/react-async/package.json b/packages/react-async/package.json
index b74c2defb..5e600b81c 100644
--- a/packages/react-async/package.json
+++ b/packages/react-async/package.json
@@ -27,8 +27,7 @@
},
"dependencies": {
"@quilted/async": "workspace:^0.4.1",
- "@quilted/react-assets": "workspace:^0.1.1",
- "@quilted/react-server-render": "workspace:^0.4.0",
+ "@quilted/react-browser": "workspace:^0.0.0",
"@quilted/react-signals": "workspace:^0.2.4"
},
"peerDependencies": {
diff --git a/packages/react-async/source/AsyncComponent.tsx b/packages/react-async/source/AsyncComponent.tsx
index 54daa62e3..26d0c16ab 100644
--- a/packages/react-async/source/AsyncComponent.tsx
+++ b/packages/react-async/source/AsyncComponent.tsx
@@ -6,7 +6,7 @@ import {
type ComponentType,
} from 'react';
import {AsyncModule, type AsyncModuleLoader} from '@quilted/async';
-import type {AssetLoadTiming} from '@quilted/react-assets';
+import type {AssetLoadTiming} from '@quilted/react-browser/server';
import {useHydrated} from './hooks/hydration.ts';
import {useAsyncModuleAssets} from './hooks/module.ts';
diff --git a/packages/react-async/source/hooks/module.ts b/packages/react-async/source/hooks/module.ts
index f2ae58bcc..95ed7caa1 100644
--- a/packages/react-async/source/hooks/module.ts
+++ b/packages/react-async/source/hooks/module.ts
@@ -1,6 +1,6 @@
import {useEffect} from 'react';
import type {AsyncModule} from '@quilted/async';
-import {useModuleAssets} from '@quilted/react-assets';
+import {useModuleAssets} from '@quilted/react-browser/server';
export function useAsyncModule(
asyncModule: AsyncModule,
diff --git a/packages/react-async/tsconfig.json b/packages/react-async/tsconfig.json
index 599b22ef0..5b531af56 100644
--- a/packages/react-async/tsconfig.json
+++ b/packages/react-async/tsconfig.json
@@ -8,8 +8,7 @@
"exclude": ["**/*.test.ts", "**/*.test.tsx"],
"references": [
{"path": "../async"},
- {"path": "../react-assets"},
- {"path": "../react-server-render"},
+ {"path": "../react-browser"},
{"path": "../react-signals"}
]
}
diff --git a/packages/react-browser/README.md b/packages/react-browser/README.md
new file mode 100644
index 000000000..2a488a43b
--- /dev/null
+++ b/packages/react-browser/README.md
@@ -0,0 +1 @@
+# `@quilted/react-browser`
diff --git a/packages/react-assets/package.json b/packages/react-browser/package.json
similarity index 67%
rename from packages/react-assets/package.json
rename to packages/react-browser/package.json
index 989a88533..373bb745c 100644
--- a/packages/react-assets/package.json
+++ b/packages/react-browser/package.json
@@ -1,20 +1,19 @@
{
- "name": "@quilted/react-assets",
- "description": "React bindings for working with browser assets",
+ "name": "@quilted/react-browser",
"type": "module",
"license": "MIT",
"publishConfig": {
"access": "public",
"@quilted/registry": "https://registry.npmjs.org"
},
- "version": "0.1.1",
+ "version": "0.0.0",
"engines": {
- "node": ">=14.0.0"
+ "node": ">=18.0.0"
},
"repository": {
"type": "git",
"url": "https://github.com/lemonmade/quilt.git",
- "directory": "packages/react-assets"
+ "directory": "packages/react-browser"
},
"exports": {
".": {
@@ -40,15 +39,17 @@
},
"sideEffects": false,
"scripts": {
- "build": "rollup --config configuration/rollup.config.js"
+ "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/browser": "workspace:^0.0.0",
+ "@quilted/http": "workspace:^0.3.0",
+ "@quilted/react-utilities": "workspace:^0.2.1",
+ "@quilted/signals": "workspace:^0.2.1"
},
"peerDependencies": {
- "react": "^17.0.0 || ^18.0.0"
+ "react": "^18.0.0"
},
"peerDependenciesMeta": {
"react": {
@@ -57,5 +58,9 @@
},
"devDependencies": {
"react": "workspace:@quilted/react@^18.2.0"
- }
+ },
+ "browserslist": [
+ "defaults and fully supports es6-module",
+ "maintained node versions"
+ ]
}
diff --git a/packages/react-assets/configuration/rollup.config.js b/packages/react-browser/rollup.config.js
similarity index 100%
rename from packages/react-assets/configuration/rollup.config.js
rename to packages/react-browser/rollup.config.js
diff --git a/packages/react-html/source/components/Alternate.tsx b/packages/react-browser/source/components/Alternate.tsx
similarity index 100%
rename from packages/react-html/source/components/Alternate.tsx
rename to packages/react-browser/source/components/Alternate.tsx
diff --git a/packages/react-html/source/components/BodyAttributes.tsx b/packages/react-browser/source/components/BodyAttributes.tsx
similarity index 100%
rename from packages/react-html/source/components/BodyAttributes.tsx
rename to packages/react-browser/source/components/BodyAttributes.tsx
diff --git a/packages/react-browser/source/components/BrowserContext.tsx b/packages/react-browser/source/components/BrowserContext.tsx
new file mode 100644
index 000000000..e6efa005a
--- /dev/null
+++ b/packages/react-browser/source/components/BrowserContext.tsx
@@ -0,0 +1,17 @@
+import type {PropsWithChildren} from 'react';
+import type {BrowserDetails} from '@quilted/browser';
+import {BrowserDetailsContext} from '../context.ts';
+
+/**
+ * Provides details about the browser to your React app.
+ */
+export function BrowserContext({
+ browser,
+ children,
+}: PropsWithChildren<{browser: BrowserDetails}>) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/packages/react-html/source/components/Favicon.tsx b/packages/react-browser/source/components/Favicon.tsx
similarity index 100%
rename from packages/react-html/source/components/Favicon.tsx
rename to packages/react-browser/source/components/Favicon.tsx
diff --git a/packages/react-html/source/components/HTMLAttributes.tsx b/packages/react-browser/source/components/HTMLAttributes.tsx
similarity index 100%
rename from packages/react-html/source/components/HTMLAttributes.tsx
rename to packages/react-browser/source/components/HTMLAttributes.tsx
diff --git a/packages/react-html/source/components/Link.tsx b/packages/react-browser/source/components/Link.tsx
similarity index 100%
rename from packages/react-html/source/components/Link.tsx
rename to packages/react-browser/source/components/Link.tsx
diff --git a/packages/react-html/source/components/Meta.tsx b/packages/react-browser/source/components/Meta.tsx
similarity index 100%
rename from packages/react-html/source/components/Meta.tsx
rename to packages/react-browser/source/components/Meta.tsx
diff --git a/packages/react-html/source/components/ThemeColor.tsx b/packages/react-browser/source/components/ThemeColor.tsx
similarity index 94%
rename from packages/react-html/source/components/ThemeColor.tsx
rename to packages/react-browser/source/components/ThemeColor.tsx
index 05944c3f4..631808825 100644
--- a/packages/react-html/source/components/ThemeColor.tsx
+++ b/packages/react-browser/source/components/ThemeColor.tsx
@@ -7,7 +7,7 @@ export interface Props {
* The theme-color to use, which supporting browsers will apply
* to some elements of the UI.
*/
- value: string;
+ value: Parameters[0];
/**
* The color scheme that this theme color applies to. By default,
diff --git a/packages/react-html/source/components/Title.tsx b/packages/react-browser/source/components/Title.tsx
similarity index 83%
rename from packages/react-html/source/components/Title.tsx
rename to packages/react-browser/source/components/Title.tsx
index 89eb2e4ab..4f173d9c7 100644
--- a/packages/react-html/source/components/Title.tsx
+++ b/packages/react-browser/source/components/Title.tsx
@@ -8,7 +8,7 @@ import {useTitle} from '../hooks/title.ts';
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/Document/title
*/
-export function Title({children}: {children: string}) {
+export function Title({children}: {children: Parameters[0]}) {
useTitle(children);
return null;
}
diff --git a/packages/react-browser/source/context.ts b/packages/react-browser/source/context.ts
new file mode 100644
index 000000000..4e0320dff
--- /dev/null
+++ b/packages/react-browser/source/context.ts
@@ -0,0 +1,8 @@
+import {
+ createOptionalContext,
+ createUseContextHook,
+} from '@quilted/react-utilities';
+import type {BrowserDetails} from '@quilted/browser';
+
+export const BrowserDetailsContext = createOptionalContext();
+export const useBrowserDetails = createUseContextHook(BrowserDetailsContext);
diff --git a/packages/react-html/source/hooks/alternate-url.ts b/packages/react-browser/source/hooks/alternate-url.ts
similarity index 51%
rename from packages/react-html/source/hooks/alternate-url.ts
rename to packages/react-browser/source/hooks/alternate-url.ts
index 110fa8509..9330ef862 100644
--- a/packages/react-html/source/hooks/alternate-url.ts
+++ b/packages/react-browser/source/hooks/alternate-url.ts
@@ -1,4 +1,9 @@
-import {useDomEffect} from './dom-effect.ts';
+import {
+ computed,
+ resolveSignalOrValue,
+ type ReadonlySignal,
+} from '@quilted/signals';
+import {useBrowserEffect} from './browser-effect.ts';
export type Options =
| {
@@ -22,19 +27,26 @@ export type Options =
* Adds a `` tag that specifies an alternate URL for this page.
*/
export function useAlternateUrl(
- url: string | URL,
+ url: string | URL | ReadonlySignal,
{canonical, locale}: Options,
) {
- useDomEffect(
- (manager) =>
- manager.addLink(
+ useBrowserEffect(
+ (browser) => {
+ const link = computed(() =>
canonical
? {
rel: 'canonical',
- href: url.toString(),
+ href: resolveSignalOrValue(url).toString(),
}
- : {rel: 'alternate', href: url.toString(), hrefLang: locale},
- ),
- [canonical, locale],
+ : {
+ rel: 'alternate',
+ href: resolveSignalOrValue(url).toString(),
+ hrefLang: locale,
+ },
+ );
+
+ return browser.links.add(link);
+ },
+ [url, canonical, locale],
);
}
diff --git a/packages/react-browser/source/hooks/body-attributes.ts b/packages/react-browser/source/hooks/body-attributes.ts
new file mode 100644
index 000000000..a3f3545f2
--- /dev/null
+++ b/packages/react-browser/source/hooks/body-attributes.ts
@@ -0,0 +1,26 @@
+import {isSignal, type ReadonlySignal} from '@quilted/signals';
+import type {BrowserBodyAttributes} from '@quilted/browser';
+import {useBrowserEffect} from './browser-effect.ts';
+
+/**
+ * Sets the provided attributes on the `` element.
+ */
+export function useBodyAttributes(
+ bodyAttributes:
+ | false
+ | null
+ | undefined
+ | Partial
+ | ReadonlySignal,
+) {
+ useBrowserEffect(
+ (browser) => {
+ if (bodyAttributes) return browser.bodyAttributes.add(bodyAttributes);
+ },
+ [
+ isSignal(bodyAttributes) || !bodyAttributes
+ ? bodyAttributes
+ : JSON.stringify(bodyAttributes),
+ ],
+ );
+}
diff --git a/packages/react-browser/source/hooks/browser-effect.ts b/packages/react-browser/source/hooks/browser-effect.ts
new file mode 100644
index 000000000..46a1368c3
--- /dev/null
+++ b/packages/react-browser/source/hooks/browser-effect.ts
@@ -0,0 +1,22 @@
+import {useEffect} from 'react';
+import type {BrowserDetails} from '@quilted/browser';
+
+import {useBrowserDetails} from '../context.ts';
+
+const EMPTY_DEPENDENCIES = Object.freeze([]);
+
+export function useBrowserEffect(
+ perform: (browser: BrowserDetails) => void | (() => void),
+ dependencies: readonly any[] = EMPTY_DEPENDENCIES,
+) {
+ const browser = useBrowserDetails();
+
+ if (typeof document === 'undefined') {
+ perform(browser);
+ return;
+ }
+
+ useEffect(() => {
+ return perform(browser);
+ }, [browser, ...dependencies]);
+}
diff --git a/packages/react-browser/source/hooks/browser-request.ts b/packages/react-browser/source/hooks/browser-request.ts
new file mode 100644
index 000000000..add0a6fc4
--- /dev/null
+++ b/packages/react-browser/source/hooks/browser-request.ts
@@ -0,0 +1,3 @@
+import {useBrowserDetails} from '../context.ts';
+
+export const useBrowserRequest = () => useBrowserDetails().request;
diff --git a/packages/react-http/source/hooks/cookie.ts b/packages/react-browser/source/hooks/cookie.ts
similarity index 54%
rename from packages/react-http/source/hooks/cookie.ts
rename to packages/react-browser/source/hooks/cookie.ts
index 8311c80b4..aae53641b 100644
--- a/packages/react-http/source/hooks/cookie.ts
+++ b/packages/react-browser/source/hooks/cookie.ts
@@ -1,7 +1,4 @@
-import {useMemo, useSyncExternalStore} from 'react';
-import {createUseContextHook} from '@quilted/react-utilities';
-
-import {HttpCookiesContext} from '../context.ts';
+import {useBrowserDetails} from '../context.ts';
/**
* Provides access to the cookie manager for the application. This
@@ -13,7 +10,9 @@ import {HttpCookiesContext} from '../context.ts';
* you can use the `useResponseCookie` or `useDeleteResponseCookie` hooks,
* or the `ResponseCookie` component.
*/
-export const useCookies = createUseContextHook(HttpCookiesContext);
+export function useCookies() {
+ return useBrowserDetails().cookies;
+}
/**
* Provides the current value of the requested cookie. When run on the
@@ -23,21 +22,5 @@ export const useCookies = createUseContextHook(HttpCookiesContext);
* from the HTTP request during server-side rendering.
*/
export function useCookie(cookie: string) {
- const cookies = useCookies();
-
- const value = useSyncExternalStore(
- ...useMemo>>(
- () => [
- (callback) => {
- const abort = new AbortController();
- cookies.subscribe(cookie, callback, {signal: abort.signal});
- return () => abort.abort();
- },
- () => cookies.get(cookie),
- ],
- [cookie, cookies],
- ),
- );
-
- return value;
+ return useCookies().get(cookie);
}
diff --git a/packages/react-html/source/hooks/favicon.ts b/packages/react-browser/source/hooks/favicon.ts
similarity index 81%
rename from packages/react-html/source/hooks/favicon.ts
rename to packages/react-browser/source/hooks/favicon.ts
index 4f3860eef..8b50cf7e6 100644
--- a/packages/react-html/source/hooks/favicon.ts
+++ b/packages/react-browser/source/hooks/favicon.ts
@@ -1,4 +1,4 @@
-import {useDomEffect} from './dom-effect.ts';
+import {useBrowserEffect} from './browser-effect.ts';
export interface FaviconOptions {
/**
@@ -12,9 +12,9 @@ export interface FaviconOptions {
* Adds a favicon to your website, using a `` tag.
*/
export function useFavicon(source: string, {type}: FaviconOptions = {}) {
- useDomEffect(
- (manager) =>
- manager.addLink(
+ useBrowserEffect(
+ (browser) =>
+ browser.links.add(
type
? {
rel: 'icon',
diff --git a/packages/react-browser/source/hooks/html-attributes.ts b/packages/react-browser/source/hooks/html-attributes.ts
new file mode 100644
index 000000000..b41a25344
--- /dev/null
+++ b/packages/react-browser/source/hooks/html-attributes.ts
@@ -0,0 +1,26 @@
+import {isSignal, type ReadonlySignal} from '@quilted/signals';
+import type {BrowserHTMLAttributes} from '@quilted/browser';
+import {useBrowserEffect} from './browser-effect.ts';
+
+/**
+ * Sets the provided attributes on the `` element.
+ */
+export function useHTMLAttributes(
+ htmlAttributes:
+ | false
+ | null
+ | undefined
+ | Partial
+ | ReadonlySignal,
+) {
+ useBrowserEffect(
+ (browser) => {
+ if (htmlAttributes) return browser.htmlAttributes.add(htmlAttributes);
+ },
+ [
+ isSignal(htmlAttributes) || !htmlAttributes
+ ? htmlAttributes
+ : JSON.stringify(htmlAttributes),
+ ],
+ );
+}
diff --git a/packages/react-browser/source/hooks/link.ts b/packages/react-browser/source/hooks/link.ts
new file mode 100644
index 000000000..fe61abb85
--- /dev/null
+++ b/packages/react-browser/source/hooks/link.ts
@@ -0,0 +1,27 @@
+import type {BrowserLinkAttributes} from '@quilted/browser';
+import {isSignal, type ReadonlySignal} from '@quilted/signals';
+
+import {useBrowserEffect} from './browser-effect.ts';
+
+/**
+ * Adds a `` tag to the `` of the document with the
+ * provided attributes. If you want to conditionally disable or
+ * remove the tag, you can instead pass `false` to this hook.
+ *
+ * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/link
+ */
+export function useLink(
+ link:
+ | false
+ | null
+ | undefined
+ | BrowserLinkAttributes
+ | ReadonlySignal,
+) {
+ useBrowserEffect(
+ (browser) => {
+ if (link) return browser.links.add(link);
+ },
+ [isSignal(link) || !link ? link : JSON.stringify(link)],
+ );
+}
diff --git a/packages/react-browser/source/hooks/locale.ts b/packages/react-browser/source/hooks/locale.ts
new file mode 100644
index 000000000..9eeb3950b
--- /dev/null
+++ b/packages/react-browser/source/hooks/locale.ts
@@ -0,0 +1,8 @@
+import {useBrowserEffect} from './browser-effect.ts';
+
+export function useLocale(locale: string) {
+ useBrowserEffect(
+ (browser) => browser.htmlAttributes.add({lang: locale}),
+ [locale],
+ );
+}
diff --git a/packages/react-browser/source/hooks/meta.ts b/packages/react-browser/source/hooks/meta.ts
new file mode 100644
index 000000000..f1aa9311b
--- /dev/null
+++ b/packages/react-browser/source/hooks/meta.ts
@@ -0,0 +1,27 @@
+import type {BrowserMetaAttributes} from '@quilted/browser';
+import {isSignal, type ReadonlySignal} from '@quilted/signals';
+
+import {useBrowserEffect} from './browser-effect.ts';
+
+/**
+ * Adds a `` tag to the `` of the document with the
+ * provided attributes. If you want to conditionally disable or
+ * remove the tag, you can instead pass `false` to this hook.
+ *
+ * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta
+ */
+export function useMeta(
+ meta:
+ | false
+ | null
+ | undefined
+ | BrowserMetaAttributes
+ | ReadonlySignal,
+) {
+ useBrowserEffect(
+ (browser) => {
+ if (meta) return browser.metas.add(meta);
+ },
+ [isSignal(meta) || !meta ? meta : JSON.stringify(meta)],
+ );
+}
diff --git a/packages/react-browser/source/hooks/serialized.ts b/packages/react-browser/source/hooks/serialized.ts
new file mode 100644
index 000000000..fe29885b8
--- /dev/null
+++ b/packages/react-browser/source/hooks/serialized.ts
@@ -0,0 +1,5 @@
+import {useBrowserDetails} from '../context.ts';
+
+export function useSerialized(id: string): T {
+ return useBrowserDetails().serializations.get(id);
+}
diff --git a/packages/react-browser/source/hooks/theme-color.ts b/packages/react-browser/source/hooks/theme-color.ts
new file mode 100644
index 000000000..f2c209251
--- /dev/null
+++ b/packages/react-browser/source/hooks/theme-color.ts
@@ -0,0 +1,49 @@
+import {
+ computed,
+ resolveSignalOrValue,
+ type ReadonlySignal,
+} from '@quilted/signals';
+import {useBrowserEffect} from './browser-effect.ts';
+
+interface Options {
+ /**
+ * The color scheme that this theme color applies to. By default,
+ * the theme color applies for all color schemes.
+ */
+ prefersColorScheme?: 'light' | 'dark';
+}
+
+/**
+ * Adds a `theme-color` `` tag to the `` of the document.
+ * You can optionally pass the `prefersColorScheme` option, which can
+ * be either `'light'` or `'dark'`, to limit this meta tag to specific
+ * color schemes in browsers that support it.
+ *
+ * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta/name/theme-color
+ */
+export function useThemeColor(
+ color: string | ReadonlySignal,
+ {prefersColorScheme}: Options,
+) {
+ useBrowserEffect(
+ (browser) => {
+ const meta = computed(() => {
+ const meta = {
+ name: 'theme-color',
+ content: resolveSignalOrValue(color),
+ };
+
+ if (prefersColorScheme) {
+ Object.assign(meta, {
+ media: `(prefers-color-scheme: ${prefersColorScheme})`,
+ });
+ }
+
+ return meta;
+ });
+
+ return browser.metas.add(meta);
+ },
+ [color, prefersColorScheme],
+ );
+}
diff --git a/packages/react-html/source/hooks/title.ts b/packages/react-browser/source/hooks/title.ts
similarity index 59%
rename from packages/react-html/source/hooks/title.ts
rename to packages/react-browser/source/hooks/title.ts
index 2d008bef9..eb7946a3f 100644
--- a/packages/react-html/source/hooks/title.ts
+++ b/packages/react-browser/source/hooks/title.ts
@@ -1,4 +1,5 @@
-import {useDomEffect} from './dom-effect.ts';
+import {ReadonlySignal} from '@quilted/signals';
+import {useBrowserEffect} from './browser-effect.ts';
/**
* Adds a `` tag to the `` of the document with the
@@ -10,10 +11,12 @@ import {useDomEffect} from './dom-effect.ts';
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/Document/title
*/
-export function useTitle(title: false | string) {
- useDomEffect(
- (manager) => {
- if (title) return manager.addTitle(title);
+export function useTitle(
+ title: false | null | undefined | string | ReadonlySignal,
+) {
+ useBrowserEffect(
+ (browser) => {
+ if (title || title === '') return browser.title.add(title);
},
[title],
);
diff --git a/packages/react-browser/source/index.ts b/packages/react-browser/source/index.ts
new file mode 100644
index 000000000..44db01eb6
--- /dev/null
+++ b/packages/react-browser/source/index.ts
@@ -0,0 +1,27 @@
+export * from '@quilted/browser';
+
+export {BrowserDetailsContext, useBrowserDetails} from './context.ts';
+
+export {useAlternateUrl} from './hooks/alternate-url.ts';
+export {useBodyAttributes} from './hooks/body-attributes.ts';
+export {useBrowserEffect} from './hooks/browser-effect.ts';
+export {useBrowserRequest} from './hooks/browser-request.ts';
+export {useCookie, useCookies} from './hooks/cookie.ts';
+export {useFavicon} from './hooks/favicon.ts';
+export {useHTMLAttributes} from './hooks/html-attributes.ts';
+export {useLink} from './hooks/link.ts';
+export {useLocale} from './hooks/locale.ts';
+export {useMeta} from './hooks/meta.ts';
+export {useSerialized} from './hooks/serialized.ts';
+export {useThemeColor} from './hooks/theme-color.ts';
+export {useTitle} from './hooks/title.ts';
+
+export {Alternate} from './components/Alternate.tsx';
+export {BrowserContext} from './components/BrowserContext.tsx';
+export {BodyAttributes} from './components/BodyAttributes.tsx';
+export {HTMLAttributes} from './components/HTMLAttributes.tsx';
+export {Link} from './components/Link.tsx';
+export {Meta} from './components/Meta.tsx';
+export {ThemeColor} from './components/ThemeColor.tsx';
+export {Title} from './components/Title.tsx';
+export {Favicon} from './components/Favicon.tsx';
diff --git a/packages/react-browser/source/server.ts b/packages/react-browser/source/server.ts
new file mode 100644
index 000000000..12743317e
--- /dev/null
+++ b/packages/react-browser/source/server.ts
@@ -0,0 +1,52 @@
+export {
+ HttpMethod,
+ ResponseType,
+ StatusCode,
+ ContentSecurityPolicyDirective,
+ ContentSecurityPolicySandboxAllow,
+ ContentSecurityPolicySpecialSource,
+ PermissionsPolicyDirective,
+ PermissionsPolicySpecialSource,
+} from '@quilted/http';
+export type {AssetLoadTiming} from '@quilted/assets';
+export * from '@quilted/browser/server';
+
+export {BrowserDetailsContext} from './context.ts';
+
+export {useAssetsCacheKey, useModuleAssets} from './server/hooks/assets.ts';
+export {useBrowserResponseAction} from './server/hooks/browser-response-action.ts';
+export {useCacheControl} from './server/hooks/cache-control.ts';
+export {useContentSecurityPolicy} from './server/hooks/content-security-policy.ts';
+export {useCrossOriginEmbedderPolicy} from './server/hooks/cross-origin-embedder-policy.ts';
+export {useCrossOriginOpenerPolicy} from './server/hooks/cross-origin-opener-policy.ts';
+export {useCrossOriginResourcePolicy} from './server/hooks/cross-origin-resource-policy.ts';
+export {usePermissionsPolicy} from './server/hooks/permissions-policy.ts';
+export {useResponseRedirect} from './server/hooks/redirect.ts';
+export {useResponseHeader} from './server/hooks/response-header.ts';
+export {
+ useDeleteResponseCookie,
+ useResponseCookie,
+} from './server/hooks/response-cookie.ts';
+export {useResponseStatus} from './server/hooks/response-status.ts';
+export {useSearchRobots} from './server/hooks/search-robots.ts';
+export {useStrictTransportSecurity} from './server/hooks/strict-transport-security.ts';
+export {useViewport} from './server/hooks/viewport.ts';
+
+export {CacheControl} from './server/components/CacheControl.tsx';
+export {ContentSecurityPolicy} from './server/components/ContentSecurityPolicy.tsx';
+export {CrossOriginEmbedderPolicy} from './server/components/CrossOriginEmbedderPolicy.tsx';
+export {CrossOriginOpenerPolicy} from './server/components/CrossOriginOpenerPolicy.tsx';
+export {CrossOriginResourcePolicy} from './server/components/CrossOriginResourcePolicy.tsx';
+export {NotFound} from './server/components/NotFound.tsx';
+export {PermissionsPolicy} from './server/components/PermissionsPolicy.tsx';
+export {ResponseCookie} from './server/components/ResponseCookie.tsx';
+export {ResponseHeader} from './server/components/ResponseHeader.tsx';
+export {ResponseStatus} from './server/components/ResponseStatus.tsx';
+export {ScriptAsset} from './server/components/ScriptAsset.tsx';
+export {ScriptAssetPreload} from './server/components/ScriptAssetPreload.tsx';
+export {SearchRobots} from './server/components/SearchRobots.tsx';
+export {Serialize} from './server/components/Serialize.tsx';
+export {StrictTransportSecurity} from './server/components/StrictTransportSecurity.tsx';
+export {StyleAsset} from './server/components/StyleAsset.tsx';
+export {StyleAssetPreload} from './server/components/StyleAssetPreload.tsx';
+export {Viewport} from './server/components/Viewport.tsx';
diff --git a/packages/react-http/source/components/CacheControl.tsx b/packages/react-browser/source/server/components/CacheControl.tsx
similarity index 91%
rename from packages/react-http/source/components/CacheControl.tsx
rename to packages/react-browser/source/server/components/CacheControl.tsx
index b2111f9bf..56716e25b 100644
--- a/packages/react-http/source/components/CacheControl.tsx
+++ b/packages/react-browser/source/server/components/CacheControl.tsx
@@ -16,6 +16,7 @@ type Props =
* A component that sets the `Cache-Control` header for this request.
*/
export function CacheControl(options: Props) {
+ if (typeof document === 'object') return null;
useCacheControl('value' in options ? options.value : options);
return null;
}
diff --git a/packages/react-http/source/components/ContentSecurityPolicy.tsx b/packages/react-browser/source/server/components/ContentSecurityPolicy.tsx
similarity index 93%
rename from packages/react-http/source/components/ContentSecurityPolicy.tsx
rename to packages/react-browser/source/server/components/ContentSecurityPolicy.tsx
index 98d35e819..b5a5ca70e 100644
--- a/packages/react-http/source/components/ContentSecurityPolicy.tsx
+++ b/packages/react-browser/source/server/components/ContentSecurityPolicy.tsx
@@ -18,6 +18,7 @@ type Props =
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy
*/
export function ContentSecurityPolicy(options: Props) {
+ if (typeof document === 'object') return null;
useContentSecurityPolicy('value' in options ? options.value : options);
return null;
}
diff --git a/packages/react-http/source/components/CrossOriginEmbedderPolicy.tsx b/packages/react-browser/source/server/components/CrossOriginEmbedderPolicy.tsx
similarity index 90%
rename from packages/react-http/source/components/CrossOriginEmbedderPolicy.tsx
rename to packages/react-browser/source/server/components/CrossOriginEmbedderPolicy.tsx
index 8f14a44db..44c50f812 100644
--- a/packages/react-http/source/components/CrossOriginEmbedderPolicy.tsx
+++ b/packages/react-browser/source/server/components/CrossOriginEmbedderPolicy.tsx
@@ -11,6 +11,7 @@ export function CrossOriginEmbedderPolicy({
}: {
value: Parameters[0];
}) {
+ if (typeof document === 'object') return null;
useCrossOriginEmbedderPolicy(value);
return null;
}
diff --git a/packages/react-http/source/components/CrossOriginOpenerPolicy.tsx b/packages/react-browser/source/server/components/CrossOriginOpenerPolicy.tsx
similarity index 90%
rename from packages/react-http/source/components/CrossOriginOpenerPolicy.tsx
rename to packages/react-browser/source/server/components/CrossOriginOpenerPolicy.tsx
index 538e6ba32..8e76afe9a 100644
--- a/packages/react-http/source/components/CrossOriginOpenerPolicy.tsx
+++ b/packages/react-browser/source/server/components/CrossOriginOpenerPolicy.tsx
@@ -11,6 +11,7 @@ export function CrossOriginOpenerPolicy({
}: {
value: Parameters[0];
}) {
+ if (typeof document === 'object') return null;
useCrossOriginOpenerPolicy(value);
return null;
}
diff --git a/packages/react-http/source/components/CrossOriginResourcePolicy.tsx b/packages/react-browser/source/server/components/CrossOriginResourcePolicy.tsx
similarity index 90%
rename from packages/react-http/source/components/CrossOriginResourcePolicy.tsx
rename to packages/react-browser/source/server/components/CrossOriginResourcePolicy.tsx
index 48c65d3ec..ace9d718f 100644
--- a/packages/react-http/source/components/CrossOriginResourcePolicy.tsx
+++ b/packages/react-browser/source/server/components/CrossOriginResourcePolicy.tsx
@@ -11,6 +11,7 @@ export function CrossOriginResourcePolicy({
}: {
value: Parameters[0];
}) {
+ if (typeof document === 'object') return null;
useCrossOriginResourcePolicy(value);
return null;
}
diff --git a/packages/react-http/source/components/NotFound.tsx b/packages/react-browser/source/server/components/NotFound.tsx
similarity index 81%
rename from packages/react-http/source/components/NotFound.tsx
rename to packages/react-browser/source/server/components/NotFound.tsx
index 9a2b9dbdf..addc29eca 100644
--- a/packages/react-http/source/components/NotFound.tsx
+++ b/packages/react-browser/source/server/components/NotFound.tsx
@@ -4,6 +4,7 @@ import {useResponseStatus} from '../hooks/response-status.ts';
* This component sets a 404 status code on the current response.
*/
export function NotFound() {
+ if (typeof document === 'object') return null;
useResponseStatus(404);
return null;
}
diff --git a/packages/react-http/source/components/PermissionsPolicy.tsx b/packages/react-browser/source/server/components/PermissionsPolicy.tsx
similarity index 92%
rename from packages/react-http/source/components/PermissionsPolicy.tsx
rename to packages/react-browser/source/server/components/PermissionsPolicy.tsx
index abfb0b19f..8b56d2981 100644
--- a/packages/react-http/source/components/PermissionsPolicy.tsx
+++ b/packages/react-browser/source/server/components/PermissionsPolicy.tsx
@@ -18,6 +18,7 @@ type Props =
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Feature-Policy
*/
export function PermissionsPolicy(options: Props) {
+ if (typeof document === 'object') return null;
usePermissionsPolicy('value' in options ? options.value : options);
return null;
}
diff --git a/packages/react-http/source/components/ResponseCookie.tsx b/packages/react-browser/source/server/components/ResponseCookie.tsx
similarity index 85%
rename from packages/react-http/source/components/ResponseCookie.tsx
rename to packages/react-browser/source/server/components/ResponseCookie.tsx
index ccac5aabe..abe797818 100644
--- a/packages/react-http/source/components/ResponseCookie.tsx
+++ b/packages/react-browser/source/server/components/ResponseCookie.tsx
@@ -1,5 +1,5 @@
import type {CookieOptions} from '@quilted/http';
-import {useHttpAction} from '../hooks/http-action.ts';
+import {useBrowserResponseAction} from '../hooks/browser-response-action.ts';
export interface DeleteProps {
/**
@@ -61,11 +61,13 @@ export function ResponseCookie({
delete: shouldDelete,
...options
}: Props) {
- useHttpAction((http) => {
+ if (typeof document === 'object') return null;
+
+ useBrowserResponseAction((response) => {
if (shouldDelete) {
- http.cookies.delete(name, options);
+ response.cookies.delete(name, options);
} else {
- http.cookies.set(name, value!, options);
+ response.cookies.set(name, value!, options);
}
});
diff --git a/packages/react-http/source/components/ResponseHeader.tsx b/packages/react-browser/source/server/components/ResponseHeader.tsx
similarity index 89%
rename from packages/react-http/source/components/ResponseHeader.tsx
rename to packages/react-browser/source/server/components/ResponseHeader.tsx
index 5e88d99f3..8ca5659fe 100644
--- a/packages/react-http/source/components/ResponseHeader.tsx
+++ b/packages/react-browser/source/server/components/ResponseHeader.tsx
@@ -17,6 +17,7 @@ interface Props {
* server-side rendering.
*/
export function ResponseHeader({name, value}: Props) {
+ if (typeof document === 'object') return null;
useResponseHeader(name, value);
return null;
}
diff --git a/packages/react-http/source/components/ResponseStatus.tsx b/packages/react-browser/source/server/components/ResponseStatus.tsx
similarity index 92%
rename from packages/react-http/source/components/ResponseStatus.tsx
rename to packages/react-browser/source/server/components/ResponseStatus.tsx
index f957099af..a55a2b865 100644
--- a/packages/react-http/source/components/ResponseStatus.tsx
+++ b/packages/react-browser/source/server/components/ResponseStatus.tsx
@@ -16,6 +16,7 @@ export interface Props {
* This component only works during server-side rendering.
*/
export function ResponseStatus({code}: Props) {
+ if (typeof document === 'object') return null;
useResponseStatus(code);
return null;
}
diff --git a/packages/react-html/source/server/components/Script.tsx b/packages/react-browser/source/server/components/ScriptAsset.tsx
similarity index 63%
rename from packages/react-html/source/server/components/Script.tsx
rename to packages/react-browser/source/server/components/ScriptAsset.tsx
index c60c2efa6..ce6b46fe4 100644
--- a/packages/react-html/source/server/components/Script.tsx
+++ b/packages/react-browser/source/server/components/ScriptAsset.tsx
@@ -2,12 +2,12 @@ import {scriptAssetAttributes, type Asset} from '@quilted/assets';
export interface ScriptProps {
asset: Asset;
- baseUrl?: URL;
+ baseURL?: URL;
}
-export function Script({asset, baseUrl}: ScriptProps) {
+export function ScriptAsset({asset, baseURL}: ScriptProps) {
const attributes = scriptAssetAttributes(asset, {
- baseUrl,
+ baseURL,
});
const loadingAttribute =
@@ -15,7 +15,5 @@ export function Script({asset, baseUrl}: ScriptProps) {
? {async: attributes.async ?? true}
: {defer: attributes.defer ?? true};
- return (
-
- );
+ return ;
}
diff --git a/packages/react-html/source/server/components/ScriptPreload.tsx b/packages/react-browser/source/server/components/ScriptAssetPreload.tsx
similarity index 55%
rename from packages/react-html/source/server/components/ScriptPreload.tsx
rename to packages/react-browser/source/server/components/ScriptAssetPreload.tsx
index 3bfef94f3..3d7e88a66 100644
--- a/packages/react-html/source/server/components/ScriptPreload.tsx
+++ b/packages/react-browser/source/server/components/ScriptAssetPreload.tsx
@@ -2,13 +2,13 @@ import {scriptAssetPreloadAttributes, type Asset} from '@quilted/assets';
export interface ScriptPreloadProps {
asset: Asset;
- baseUrl?: URL;
+ baseURL?: URL;
}
-export function ScriptPreload({asset, baseUrl}: ScriptPreloadProps) {
+export function ScriptAssetPreload({asset, baseURL}: ScriptPreloadProps) {
const attributes = scriptAssetPreloadAttributes(asset, {
- baseUrl,
+ baseURL,
});
- return ;
+ return ;
}
diff --git a/packages/react-html/source/components/SearchRobots.tsx b/packages/react-browser/source/server/components/SearchRobots.tsx
similarity index 100%
rename from packages/react-html/source/components/SearchRobots.tsx
rename to packages/react-browser/source/server/components/SearchRobots.tsx
diff --git a/packages/react-browser/source/server/components/Serialize.tsx b/packages/react-browser/source/server/components/Serialize.tsx
new file mode 100644
index 000000000..bbb22a16c
--- /dev/null
+++ b/packages/react-browser/source/server/components/Serialize.tsx
@@ -0,0 +1,10 @@
+export function Serialize({id, value}: {id: string; value: T | (() => T)}) {
+ return (
+
+ );
+}
diff --git a/packages/react-http/source/components/StrictTransportSecurity.tsx b/packages/react-browser/source/server/components/StrictTransportSecurity.tsx
similarity index 95%
rename from packages/react-http/source/components/StrictTransportSecurity.tsx
rename to packages/react-browser/source/server/components/StrictTransportSecurity.tsx
index 0d0a6bf20..a344c074a 100644
--- a/packages/react-http/source/components/StrictTransportSecurity.tsx
+++ b/packages/react-browser/source/server/components/StrictTransportSecurity.tsx
@@ -24,6 +24,7 @@ type Props =
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security
*/
export function StrictTransportSecurity(options: Props) {
+ if (typeof document === 'object') return null;
useStrictTransportSecurity('value' in options ? options.value : options);
return null;
}
diff --git a/packages/react-html/source/server/components/Style.tsx b/packages/react-browser/source/server/components/StyleAsset.tsx
similarity index 54%
rename from packages/react-html/source/server/components/Style.tsx
rename to packages/react-browser/source/server/components/StyleAsset.tsx
index fb9d58ebd..859ccac1e 100644
--- a/packages/react-html/source/server/components/Style.tsx
+++ b/packages/react-browser/source/server/components/StyleAsset.tsx
@@ -2,13 +2,13 @@ import {styleAssetAttributes, type Asset} from '@quilted/assets';
export interface StyleProps {
asset: Asset;
- baseUrl?: URL;
+ baseURL?: URL;
}
-export function Style({asset, baseUrl}: StyleProps) {
+export function StyleAsset({asset, baseURL}: StyleProps) {
const attributes = styleAssetAttributes(asset, {
- baseUrl,
+ baseURL,
});
- return ;
+ return ;
}
diff --git a/packages/react-html/source/server/components/StylePreload.tsx b/packages/react-browser/source/server/components/StyleAssetPreload.tsx
similarity index 55%
rename from packages/react-html/source/server/components/StylePreload.tsx
rename to packages/react-browser/source/server/components/StyleAssetPreload.tsx
index a8651c18c..7a4760375 100644
--- a/packages/react-html/source/server/components/StylePreload.tsx
+++ b/packages/react-browser/source/server/components/StyleAssetPreload.tsx
@@ -2,13 +2,13 @@ import {styleAssetPreloadAttributes, type Asset} from '@quilted/assets';
export interface StylePreloadProps {
asset: Asset;
- baseUrl?: URL;
+ baseURL?: URL;
}
-export function StylePreload({asset, baseUrl}: StylePreloadProps) {
+export function StyleAssetPreload({asset, baseURL}: StylePreloadProps) {
const attributes = styleAssetPreloadAttributes(asset, {
- baseUrl,
+ baseURL,
});
- return ;
+ return ;
}
diff --git a/packages/react-html/source/components/Viewport.tsx b/packages/react-browser/source/server/components/Viewport.tsx
similarity index 100%
rename from packages/react-html/source/components/Viewport.tsx
rename to packages/react-browser/source/server/components/Viewport.tsx
diff --git a/packages/react-browser/source/server/hooks/assets.ts b/packages/react-browser/source/server/hooks/assets.ts
new file mode 100644
index 000000000..50851dde1
--- /dev/null
+++ b/packages/react-browser/source/server/hooks/assets.ts
@@ -0,0 +1,21 @@
+import type {AssetLoadTiming, AssetsCacheKey} from '@quilted/assets';
+import {useBrowserResponseAction} from './browser-response-action.ts';
+
+export function useAssetsCacheKey(cacheKey: Partial) {
+ if (typeof document === 'object') return;
+
+ useBrowserResponseAction((response) => {
+ response.assets.updateCacheKey(cacheKey);
+ });
+}
+
+export function useModuleAssets(
+ id?: string,
+ {scripts, styles}: {styles?: AssetLoadTiming; scripts?: AssetLoadTiming} = {},
+) {
+ if (typeof document === 'object') return;
+
+ useBrowserResponseAction((response) => {
+ if (id) response.assets.use(id, {scripts, styles});
+ });
+}
diff --git a/packages/react-browser/source/server/hooks/browser-response-action.ts b/packages/react-browser/source/server/hooks/browser-response-action.ts
new file mode 100644
index 000000000..05ef5198b
--- /dev/null
+++ b/packages/react-browser/source/server/hooks/browser-response-action.ts
@@ -0,0 +1,18 @@
+import {BrowserResponse} from '@quilted/browser/server';
+
+import {useBrowserDetails} from '../../context.ts';
+
+/**
+ * During server-side rendering, the function you pass to this hook is
+ * called with the HTTP server-rendering manager, if one is found.
+ * You typically shouldn’t need to call this hook directly, as all
+ * of the individual actions you can perform on the HTTP manager are
+ * exposed as dedicated hooks.
+ */
+export function useBrowserResponseAction(
+ perform: (response: BrowserResponse) => void,
+) {
+ if (typeof document === 'object') return;
+ const response = useBrowserDetails();
+ if (response && response instanceof BrowserResponse) perform(response);
+}
diff --git a/packages/react-http/source/hooks/cache-control.ts b/packages/react-browser/source/server/hooks/cache-control.ts
similarity index 95%
rename from packages/react-http/source/hooks/cache-control.ts
rename to packages/react-browser/source/server/hooks/cache-control.ts
index 9d89e51dd..7863a5fb7 100644
--- a/packages/react-http/source/hooks/cache-control.ts
+++ b/packages/react-browser/source/server/hooks/cache-control.ts
@@ -1,4 +1,4 @@
-import {useHttpAction} from './http-action.ts';
+import {useBrowserResponseAction} from './browser-response-action.ts';
/**
* Options for controlling the Cache-Control header of the current
@@ -83,7 +83,9 @@ export type CacheControlOptions =
* the different options you have for caching HTTP content.
*/
export function useCacheControl(value: string | CacheControlOptions) {
- useHttpAction((http) => {
+ if (typeof document === 'object') return;
+
+ useBrowserResponseAction((response) => {
let normalizedValue: string;
if (typeof value === 'string') {
@@ -122,6 +124,6 @@ export function useCacheControl(value: string | CacheControlOptions) {
if (immutable) appendToHeader('immutable');
}
- http.responseHeaders.append('Cache-Control', normalizedValue);
+ response.headers.append('Cache-Control', normalizedValue);
});
}
diff --git a/packages/react-http/source/hooks/content-security-policy.ts b/packages/react-browser/source/server/hooks/content-security-policy.ts
similarity index 98%
rename from packages/react-http/source/hooks/content-security-policy.ts
rename to packages/react-browser/source/server/hooks/content-security-policy.ts
index 09d0367bc..5ab319ab4 100644
--- a/packages/react-http/source/hooks/content-security-policy.ts
+++ b/packages/react-browser/source/server/hooks/content-security-policy.ts
@@ -3,7 +3,7 @@ import type {
ContentSecurityPolicySpecialSource,
} from '@quilted/http';
-import {useHttpAction} from './http-action.ts';
+import {useBrowserResponseAction} from './browser-response-action.ts';
/**
* Options for creating a content security policy.
@@ -344,7 +344,9 @@ export interface ContentSecurityPolicyOptions {
export function useContentSecurityPolicy(
value: string | ContentSecurityPolicyOptions,
) {
- useHttpAction((http) => {
+ if (typeof document === 'object') return;
+
+ useBrowserResponseAction((response) => {
let normalizedValue = '';
let header = 'Content-Security-Policy';
@@ -467,7 +469,7 @@ export function useContentSecurityPolicy(
}
if (normalizedValue.length > 0) {
- http.responseHeaders.append(header, normalizedValue);
+ response.headers.append(header, normalizedValue);
}
});
}
diff --git a/packages/react-http/source/hooks/cross-origin-embedder-policy.ts b/packages/react-browser/source/server/hooks/cross-origin-embedder-policy.ts
similarity index 63%
rename from packages/react-http/source/hooks/cross-origin-embedder-policy.ts
rename to packages/react-browser/source/server/hooks/cross-origin-embedder-policy.ts
index 838e4cbf1..5febb037f 100644
--- a/packages/react-http/source/hooks/cross-origin-embedder-policy.ts
+++ b/packages/react-browser/source/server/hooks/cross-origin-embedder-policy.ts
@@ -1,5 +1,5 @@
import {type CrossOriginEmbedderPolicyHeaderValue} from '@quilted/http';
-import {useHttpAction} from './http-action.ts';
+import {useBrowserResponseAction} from './browser-response-action.ts';
/**
* Sets the `Cross-Origin-Embedder-Policy` header for this request.
@@ -10,7 +10,9 @@ import {useHttpAction} from './http-action.ts';
export function useCrossOriginEmbedderPolicy(
value: CrossOriginEmbedderPolicyHeaderValue,
) {
- useHttpAction((http) => {
- http.responseHeaders.append('Cross-Origin-Embedder-Policy', value);
+ if (typeof document === 'object') return;
+
+ useBrowserResponseAction((response) => {
+ response.headers.append('Cross-Origin-Embedder-Policy', value);
});
}
diff --git a/packages/react-http/source/hooks/cross-origin-opener-policy.ts b/packages/react-browser/source/server/hooks/cross-origin-opener-policy.ts
similarity index 63%
rename from packages/react-http/source/hooks/cross-origin-opener-policy.ts
rename to packages/react-browser/source/server/hooks/cross-origin-opener-policy.ts
index 070a9aed3..ab95e29ed 100644
--- a/packages/react-http/source/hooks/cross-origin-opener-policy.ts
+++ b/packages/react-browser/source/server/hooks/cross-origin-opener-policy.ts
@@ -1,5 +1,5 @@
import {type CrossOriginOpenerPolicyHeaderValue} from '@quilted/http';
-import {useHttpAction} from './http-action.ts';
+import {useBrowserResponseAction} from './browser-response-action.ts';
/**
* Sets the `Cross-Origin-Opener-Policy` header for this request.
@@ -10,7 +10,9 @@ import {useHttpAction} from './http-action.ts';
export function useCrossOriginOpenerPolicy(
value: CrossOriginOpenerPolicyHeaderValue,
) {
- useHttpAction((http) => {
- http.responseHeaders.append('Cross-Origin-Opener-Policy', value);
+ if (typeof document === 'object') return;
+
+ useBrowserResponseAction((response) => {
+ response.headers.append('Cross-Origin-Opener-Policy', value);
});
}
diff --git a/packages/react-http/source/hooks/cross-origin-resource-policy.ts b/packages/react-browser/source/server/hooks/cross-origin-resource-policy.ts
similarity index 63%
rename from packages/react-http/source/hooks/cross-origin-resource-policy.ts
rename to packages/react-browser/source/server/hooks/cross-origin-resource-policy.ts
index 075a463d7..fe8f3f919 100644
--- a/packages/react-http/source/hooks/cross-origin-resource-policy.ts
+++ b/packages/react-browser/source/server/hooks/cross-origin-resource-policy.ts
@@ -1,5 +1,5 @@
import {type CrossOriginResourcePolicyHeaderValue} from '@quilted/http';
-import {useHttpAction} from './http-action.ts';
+import {useBrowserResponseAction} from './browser-response-action.ts';
/**
* Sets the `Cross-Origin-Resource-Policy` header for this request.
@@ -10,7 +10,9 @@ import {useHttpAction} from './http-action.ts';
export function useCrossOriginResourcePolicy(
value: CrossOriginResourcePolicyHeaderValue,
) {
- useHttpAction((http) => {
- http.responseHeaders.append('Cross-Origin-Resource-Policy', value);
+ if (typeof document === 'object') return;
+
+ useBrowserResponseAction((response) => {
+ response.headers.append('Cross-Origin-Resource-Policy', value);
});
}
diff --git a/packages/react-http/source/hooks/permissions-policy.ts b/packages/react-browser/source/server/hooks/permissions-policy.ts
similarity index 98%
rename from packages/react-http/source/hooks/permissions-policy.ts
rename to packages/react-browser/source/server/hooks/permissions-policy.ts
index 16ebb3bd5..359123566 100644
--- a/packages/react-http/source/hooks/permissions-policy.ts
+++ b/packages/react-browser/source/server/hooks/permissions-policy.ts
@@ -1,6 +1,5 @@
import type {PermissionsPolicySpecialSource} from '@quilted/http';
-
-import {useHttpAction} from './http-action.ts';
+import {useBrowserResponseAction} from './browser-response-action.ts';
/**
* Options for creating a content security policy.
@@ -222,7 +221,9 @@ const SPECIAL_SOURCES = new Set(['*', 'self', 'src']);
* @see https://w3c.github.io/webappsec-permissions-policy/#permissions-policy-http-header-field
*/
export function usePermissionsPolicy(value: string | PermissionsPolicyOptions) {
- useHttpAction((http) => {
+ if (typeof document === 'object') return;
+
+ useBrowserResponseAction((response) => {
let normalizedValue = '';
if (typeof value === 'string') {
@@ -308,7 +309,7 @@ export function usePermissionsPolicy(value: string | PermissionsPolicyOptions) {
}
if (normalizedValue.length > 0) {
- http.responseHeaders.append('Permissions-Policy', normalizedValue);
+ response.headers.append('Permissions-Policy', normalizedValue);
}
});
}
diff --git a/packages/react-http/source/hooks/redirect.ts b/packages/react-browser/source/server/hooks/redirect.ts
similarity index 54%
rename from packages/react-http/source/hooks/redirect.ts
rename to packages/react-browser/source/server/hooks/redirect.ts
index 44a28e79e..2949652b4 100644
--- a/packages/react-http/source/hooks/redirect.ts
+++ b/packages/react-browser/source/server/hooks/redirect.ts
@@ -1,5 +1,5 @@
import type {StatusCode} from '@quilted/http';
-import {useHttpAction} from './http-action.ts';
+import {useBrowserResponseAction} from './browser-response-action.ts';
/**
* Registers an HTTP redirect during server-side rendering. This will
@@ -8,5 +8,15 @@ import {useHttpAction} from './http-action.ts';
* default of using a [`302` status code](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/302).
*/
export function useResponseRedirect(to: string, statusCode?: StatusCode) {
- useHttpAction((http) => http.redirectTo(to, statusCode));
+ if (typeof document === 'object') return;
+
+ useBrowserResponseAction((response) => {
+ const headers = new Headers(response.headers);
+ headers.append('Location', new URL(to, response.request.url).href);
+
+ throw new Response(null, {
+ status: statusCode ?? 302,
+ headers,
+ });
+ });
}
diff --git a/packages/react-http/source/hooks/response-cookie.ts b/packages/react-browser/source/server/hooks/response-cookie.ts
similarity index 71%
rename from packages/react-http/source/hooks/response-cookie.ts
rename to packages/react-browser/source/server/hooks/response-cookie.ts
index 0f307cbec..0119f6d0e 100644
--- a/packages/react-http/source/hooks/response-cookie.ts
+++ b/packages/react-browser/source/server/hooks/response-cookie.ts
@@ -1,5 +1,5 @@
import type {CookieOptions} from '@quilted/http';
-import {useHttpAction} from './http-action.ts';
+import {useBrowserResponseAction} from './browser-response-action.ts';
/**
* Sets an HTTP cookie on the response. You can optionally provide
@@ -17,7 +17,11 @@ export function useResponseCookie(
value: string,
options?: CookieOptions,
) {
- useHttpAction((http) => http.cookies.set(cookie, value, options));
+ if (typeof document === 'object') return;
+
+ useBrowserResponseAction((response) => {
+ response.cookies.set(cookie, value, options);
+ });
}
/**
@@ -29,5 +33,9 @@ export function useDeleteResponseCookie(
cookie: string,
options?: Pick,
) {
- useHttpAction((http) => http.cookies.delete(cookie, options));
+ if (typeof document === 'object') return;
+
+ useBrowserResponseAction((response) => {
+ response.cookies.delete(cookie, options);
+ });
}
diff --git a/packages/react-browser/source/server/hooks/response-header.ts b/packages/react-browser/source/server/hooks/response-header.ts
new file mode 100644
index 000000000..52e966111
--- /dev/null
+++ b/packages/react-browser/source/server/hooks/response-header.ts
@@ -0,0 +1,13 @@
+import {useBrowserResponseAction} from './browser-response-action.ts';
+
+/**
+ * Appends a response header to the provided value. Only works during
+ * server-side rendering.
+ */
+export function useResponseHeader(header: string, value: string) {
+ if (typeof document === 'object') return;
+
+ useBrowserResponseAction((response) => {
+ response.headers.append(header, value);
+ });
+}
diff --git a/packages/react-http/source/hooks/response-status.ts b/packages/react-browser/source/server/hooks/response-status.ts
similarity index 65%
rename from packages/react-http/source/hooks/response-status.ts
rename to packages/react-browser/source/server/hooks/response-status.ts
index 3c5b53bfd..7c2fa046a 100644
--- a/packages/react-http/source/hooks/response-status.ts
+++ b/packages/react-browser/source/server/hooks/response-status.ts
@@ -1,5 +1,5 @@
import type {StatusCode} from '@quilted/http';
-import {useHttpAction} from './http-action.ts';
+import {useBrowserResponseAction} from './browser-response-action.ts';
/**
* Sets the HTTP response status code for this request. If multiple calls
@@ -10,5 +10,9 @@ import {useHttpAction} from './http-action.ts';
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status
*/
export function useResponseStatus(statusCode: StatusCode) {
- useHttpAction((http) => http.addStatusCode(statusCode));
+ if (typeof document === 'object') return;
+
+ useBrowserResponseAction((response) => {
+ response.status.set(statusCode);
+ });
}
diff --git a/packages/react-html/source/hooks/search-robots.ts b/packages/react-browser/source/server/hooks/search-robots.ts
similarity index 63%
rename from packages/react-html/source/hooks/search-robots.ts
rename to packages/react-browser/source/server/hooks/search-robots.ts
index eb09fcff7..73f27d40e 100644
--- a/packages/react-html/source/hooks/search-robots.ts
+++ b/packages/react-browser/source/server/hooks/search-robots.ts
@@ -1,4 +1,4 @@
-import {useDomEffect} from './dom-effect.ts';
+import {useBrowserDetails} from '../../context.ts';
export type ImagePreviewSize = 'standard' | 'large';
@@ -96,70 +96,58 @@ export function useSearchRobots({
videoPreviews,
unavailableAfter,
}: Options) {
- useDomEffect(
- (manager) => {
- const directives: string[] = [];
-
- if (index === false) {
- directives.push('noindex');
- }
-
- if (indexImages === false) {
- directives.push('noimageindex');
- }
-
- if (follow === false) {
- directives.push('nofollow');
- }
-
- if (archive === false) {
- directives.push('noarchive');
- }
-
- if (translate === false) {
- directives.push('notranslate');
- }
-
- if (snippet === false) {
- directives.push('nosnippet');
- } else if (typeof snippet === 'object') {
- directives.push(`max-snippet:${snippet.maxLength}`);
- }
-
- if (imagePreviews === false) {
- directives.push('max-image-preview:none');
- } else if (typeof imagePreviews === 'object') {
- directives.push(`max-video-preview:${imagePreviews.maxSize}`);
- }
-
- if (videoPreviews === false) {
- directives.push('max-video-preview:0');
- } else if (typeof videoPreviews === 'object') {
- directives.push(`max-video-preview:${videoPreviews.maxLength}`);
- }
-
- if (unavailableAfter != null) {
- directives.push(`unavailable_after:${unavailableAfter.toISOString()}`);
- }
-
- if (directives.length === 0) return;
-
- return manager.addMeta({
- name,
- content: directives.join(', '),
- });
- },
- [
- name,
- index,
- indexImages,
- follow,
- archive,
- translate,
- snippet,
- imagePreviews,
- videoPreviews,
- unavailableAfter,
- ],
- );
+ if (typeof document === 'object') return;
+
+ const browser = useBrowserDetails();
+
+ const directives: string[] = [];
+
+ if (index === false) {
+ directives.push('noindex');
+ }
+
+ if (indexImages === false) {
+ directives.push('noimageindex');
+ }
+
+ if (follow === false) {
+ directives.push('nofollow');
+ }
+
+ if (archive === false) {
+ directives.push('noarchive');
+ }
+
+ if (translate === false) {
+ directives.push('notranslate');
+ }
+
+ if (snippet === false) {
+ directives.push('nosnippet');
+ } else if (typeof snippet === 'object') {
+ directives.push(`max-snippet:${snippet.maxLength}`);
+ }
+
+ if (imagePreviews === false) {
+ directives.push('max-image-preview:none');
+ } else if (typeof imagePreviews === 'object') {
+ directives.push(`max-video-preview:${imagePreviews.maxSize}`);
+ }
+
+ if (videoPreviews === false) {
+ directives.push('max-video-preview:0');
+ } else if (typeof videoPreviews === 'object') {
+ directives.push(`max-video-preview:${videoPreviews.maxLength}`);
+ }
+
+ if (unavailableAfter != null) {
+ directives.push(`unavailable_after:${unavailableAfter.toISOString()}`);
+ }
+
+ if (directives.length === 0) return;
+
+ return browser.metas.add({
+ name,
+ content: directives.join(', '),
+ });
}
diff --git a/packages/react-http/source/hooks/strict-transport-security.ts b/packages/react-browser/source/server/hooks/strict-transport-security.ts
similarity index 89%
rename from packages/react-http/source/hooks/strict-transport-security.ts
rename to packages/react-browser/source/server/hooks/strict-transport-security.ts
index 55320088b..ebbe91836 100644
--- a/packages/react-http/source/hooks/strict-transport-security.ts
+++ b/packages/react-browser/source/server/hooks/strict-transport-security.ts
@@ -1,4 +1,4 @@
-import {useHttpAction} from './http-action.ts';
+import {useBrowserResponseAction} from './browser-response-action.ts';
/**
* Options for creating a content security policy.
@@ -49,7 +49,9 @@ const DEFAULT_MAX_AGE = 63_072_000;
export function useStrictTransportSecurity(
value: string | StrictTransportSecurityOptions = {},
) {
- useHttpAction((http) => {
+ if (typeof document === 'object') return;
+
+ useBrowserResponseAction((response) => {
let normalizedValue = '';
if (typeof value === 'string') {
@@ -68,7 +70,7 @@ export function useStrictTransportSecurity(
}
if (normalizedValue.length > 0) {
- http.responseHeaders.append('Strict-Transport-Security', normalizedValue);
+ response.headers.append('Strict-Transport-Security', normalizedValue);
}
});
}
diff --git a/packages/react-html/source/hooks/viewport.ts b/packages/react-browser/source/server/hooks/viewport.ts
similarity index 63%
rename from packages/react-html/source/hooks/viewport.ts
rename to packages/react-browser/source/server/hooks/viewport.ts
index a7ead652f..f23a9cffc 100644
--- a/packages/react-html/source/hooks/viewport.ts
+++ b/packages/react-browser/source/server/hooks/viewport.ts
@@ -1,4 +1,4 @@
-import {useDomEffect} from './dom-effect.ts';
+import {useBrowserDetails} from '../../context.ts';
interface Options {
/**
@@ -27,25 +27,22 @@ interface Options {
* @see https://developer.mozilla.org/en-US/docs/Web/HTML/Viewport_meta_tag
*/
export function useViewport({cover = true, interactiveWidget}: Options) {
- useDomEffect(
- (manager) => {
- const parts = [
- 'width=device-width, initial-scale=1.0, height=device-height',
- ];
-
- if (cover) {
- parts.push('viewport-fit=cover');
- }
-
- if (interactiveWidget) {
- parts.push(`interactive-widget=${interactiveWidget}`);
- }
-
- return manager.addMeta({
- name: 'viewport',
- content: parts.join(', '),
- });
- },
- [cover],
- );
+ if (typeof document === 'object') return;
+
+ const browser = useBrowserDetails();
+
+ const parts = ['width=device-width, initial-scale=1.0, height=device-height'];
+
+ if (cover) {
+ parts.push('viewport-fit=cover');
+ }
+
+ if (interactiveWidget) {
+ parts.push(`interactive-widget=${interactiveWidget}`);
+ }
+
+ browser.metas.add({
+ name: 'viewport',
+ content: parts.join(', '),
+ });
}
diff --git a/packages/react-http/tsconfig.json b/packages/react-browser/tsconfig.json
similarity index 67%
rename from packages/react-http/tsconfig.json
rename to packages/react-browser/tsconfig.json
index 53cdc2b0d..010bd1240 100644
--- a/packages/react-http/tsconfig.json
+++ b/packages/react-browser/tsconfig.json
@@ -5,11 +5,11 @@
"outDir": "build/typescript"
},
"include": ["source"],
- "exclude": ["**/*.test.ts", "**/*.test.tsx"],
+ "exclude": [],
"references": [
+ {"path": "../assets"},
+ {"path": "../browser"},
{"path": "../http"},
- {"path": "../react-html"},
- {"path": "../react-server-render"},
{"path": "../react-utilities"}
]
}
diff --git a/packages/react-browser/vite.config.js b/packages/react-browser/vite.config.js
new file mode 100644
index 000000000..403ebba65
--- /dev/null
+++ b/packages/react-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/react-dom/package.json b/packages/react-dom/package.json
index b6f55f72e..5b87e5f63 100644
--- a/packages/react-dom/package.json
+++ b/packages/react-dom/package.json
@@ -44,6 +44,6 @@
"dependencies": {
"@types/react-dom": "^18.2.0",
"preact": "^10.20.0",
- "preact-render-to-string": "^6.3.0"
+ "preact-render-to-string": "^6.4.0"
}
}
diff --git a/packages/react-email/package.json b/packages/react-email/package.json
index 36d6df500..c899db87c 100644
--- a/packages/react-email/package.json
+++ b/packages/react-email/package.json
@@ -40,8 +40,8 @@
"build": "rollup --config configuration/rollup.config.js"
},
"dependencies": {
- "@quilted/react-html": "workspace:^0.4.0",
- "@quilted/react-server-render": "workspace:^0.4.0"
+ "preact-render-to-string": "^6.4.0",
+ "@quilted/react-browser": "workspace:^0.0.0"
},
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0",
diff --git a/packages/react-email/source/constants.ts b/packages/react-email/source/constants.ts
deleted file mode 100644
index a291fcaf3..000000000
--- a/packages/react-email/source/constants.ts
+++ /dev/null
@@ -1 +0,0 @@
-export const SERVER_ACTION_ID = Symbol('email');
diff --git a/packages/react-email/source/hooks/email-action.ts b/packages/react-email/source/hooks/email-action.ts
index 3eff5f6e2..cbfcdb9a3 100644
--- a/packages/react-email/source/hooks/email-action.ts
+++ b/packages/react-email/source/hooks/email-action.ts
@@ -1,13 +1,9 @@
import {useContext} from 'react';
-import {useServerAction} from '@quilted/react-server-render';
import {EmailContext} from '../context.ts';
import type {EmailManager} from '../manager.ts';
export function useEmailAction(perform: (email: EmailManager) => void) {
const email = useContext(EmailContext);
-
- useServerAction(() => {
- if (email) perform(email);
- }, email?.actionKind);
+ if (email) perform(email);
}
diff --git a/packages/react-email/source/manager.ts b/packages/react-email/source/manager.ts
index 93a26bc45..603a44c1a 100644
--- a/packages/react-email/source/manager.ts
+++ b/packages/react-email/source/manager.ts
@@ -1,5 +1,3 @@
-import type {ServerActionKind} from '@quilted/react-server-render';
-import {SERVER_ACTION_ID} from './constants.ts';
import type {Sender} from './types.ts';
export interface State {
@@ -16,13 +14,6 @@ export interface Options {
}
export class EmailManager {
- readonly actionKind: ServerActionKind = {
- id: SERVER_ACTION_ID,
- betweenEachPass: () => {
- this.reset();
- },
- };
-
private readonly options: Required;
private subject: string | undefined;
diff --git a/packages/react-email/source/server.tsx b/packages/react-email/source/server.tsx
index 184e7c711..64a20e031 100644
--- a/packages/react-email/source/server.tsx
+++ b/packages/react-email/source/server.tsx
@@ -1,50 +1,54 @@
import type {ReactElement} from 'react';
-import {renderToStaticMarkup} from 'react-dom/server';
+import {
+ renderToStringAsync,
+ renderToStaticMarkup,
+} from 'preact-render-to-string';
-import {extract} from '@quilted/react-server-render/server';
-import type {Options as ExtractOptions} from '@quilted/react-server-render/server';
-import {Head, HTMLManager, HTMLContext} from '@quilted/react-html/server';
+import {
+ type BrowserDetails,
+ type BrowserBodyAttributes,
+ type BrowserHTMLAttributes,
+ BrowserDetailsContext,
+ BrowserResponseTitle,
+ BrowserResponseHeadElements,
+ BrowserResponseElementAttributes,
+ BrowserResponseSerializations,
+} from '@quilted/react-browser/server';
import {EmailContext} from './context.ts';
import {EmailManager} from './manager.ts';
-export type Options = ExtractOptions;
-
-export async function renderEmail(
- app: ReactElement,
- {decorate, ...rest}: Options = {},
-) {
- const html = new HTMLManager();
+export async function renderEmail(element: ReactElement) {
+ const browser = new BrowserEmailResponse();
const email = new EmailManager();
- const content = await extract(app, {
- decorate(app) {
- return (
-
-
- {decorate?.(app) ?? app}
-
-
- );
- },
- ...rest,
- });
+ const content = await renderToStringAsync(
+
+
+ {element}
+
+ ,
+ );
const {state} = email;
- const {htmlAttributes, bodyAttributes, ...headProps} = html.state;
-
return {
...state,
html:
'' +
renderToStaticMarkup(
-
+
-
+ {browser.title.value && {browser.title.value}}
+ {browser.links.value.map((link, index) => (
+
+ ))}
+ {browser.metas.value.map((meta, index) => (
+
+ ))}
,
@@ -52,3 +56,22 @@ export async function renderEmail(
plainText: state.plainText,
};
}
+
+class BrowserEmailResponse 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 serializations = new BrowserResponseSerializations();
+
+ get request(): never {
+ throw new Error('Not available in email rendering');
+ }
+
+ get cookies(): never {
+ throw new Error('Not available in email rendering');
+ }
+}
diff --git a/packages/react-email/tsconfig.json b/packages/react-email/tsconfig.json
index dd9c76fce..4f0bb0fac 100644
--- a/packages/react-email/tsconfig.json
+++ b/packages/react-email/tsconfig.json
@@ -6,5 +6,5 @@
},
"include": ["source"],
"exclude": ["**/*.test.ts", "**/*.test.tsx"],
- "references": [{"path": "../react-html"}, {"path": "../react-server-render"}]
+ "references": [{"path": "../react-browser"}]
}
diff --git a/packages/react-html/CHANGELOG.md b/packages/react-html/CHANGELOG.md
deleted file mode 100644
index 0fa51cca8..000000000
--- a/packages/react-html/CHANGELOG.md
+++ /dev/null
@@ -1,243 +0,0 @@
-# @quilted/react-html
-
-## 0.4.1
-
-### Patch Changes
-
-- [`89d63fb6`](https://github.com/lemonmade/quilt/commit/89d63fb6fd0abb90a54bff67cb38db981ba37dd8) Thanks [@lemonmade](https://github.com/lemonmade)! - Add `interactiveWidget` option to Viewport hook and component
-
-## 0.4.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/assets@0.1.0
- - @quilted/react-server-render@0.4.0
-
-## 0.3.54
-
-### Patch Changes
-
-- Updated dependencies [[`750dd6b9`](https://github.com/lemonmade/quilt/commit/750dd6b9cb6a18648cc793f57579fb0b64cb23bc)]:
- - @quilted/assets@0.0.5
-
-## 0.3.53
-
-### Patch Changes
-
-- [`88832ea7`](https://github.com/lemonmade/quilt/commit/88832ea75659ef8601ebbbe7f06a266a1df54cdf) Thanks [@lemonmade](https://github.com/lemonmade)! - Fix more PURE annotations
-
-## 0.3.52
-
-### Patch Changes
-
-- [#612](https://github.com/lemonmade/quilt/pull/612) [`bc849bc7`](https://github.com/lemonmade/quilt/commit/bc849bc740318936656162fde851b784ed6ef78f) Thanks [@lemonmade](https://github.com/lemonmade)! - Simplify app template APIs
-
-- Updated dependencies [[`bc849bc7`](https://github.com/lemonmade/quilt/commit/bc849bc740318936656162fde851b784ed6ef78f)]:
- - @quilted/assets@0.0.4
-
-## 0.3.51
-
-### Patch Changes
-
-- [`86e584a5`](https://github.com/lemonmade/quilt/commit/86e584a5e95baf609f01a91ed89ca1b45116eb29) Thanks [@lemonmade](https://github.com/lemonmade)! - Add head script and style hooks/ components
-
-## 0.3.50
-
-### Patch Changes
-
-- [`93facb53`](https://github.com/lemonmade/quilt/commit/93facb530324894667817a6d2f78baea19a3b622) Thanks [@lemonmade](https://github.com/lemonmade)! - Allow omitting React element from server renderer
-
-## 0.3.49
-
-### 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
-
-## 0.3.48
-
-### 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
-
-## 0.3.47
-
-### Patch Changes
-
-- [#508](https://github.com/lemonmade/quilt/pull/508) [`befb2aa9`](https://github.com/lemonmade/quilt/commit/befb2aa9d374aff66cbfe54fc8157522e3d3af21) Thanks [@lemonmade](https://github.com/lemonmade)! - Move logic out of HTML component
-
-## 0.3.46
-
-### Patch Changes
-
-- [`8f0f7ae1`](https://github.com/lemonmade/quilt/commit/8f0f7ae1e8f4678155bedcb0d4e5ac63a73d19d9) Thanks [@lemonmade](https://github.com/lemonmade)! - Update hydration approach for async components
-
-## 0.3.45
-
-### Patch Changes
-
-- [#495](https://github.com/lemonmade/quilt/pull/495) [`0b7db36e`](https://github.com/lemonmade/quilt/commit/0b7db36e5333067761c8a88fec5722057ab0e04f) Thanks [@lemonmade](https://github.com/lemonmade)! - Match server and browser app entries
-
-## 0.3.44
-
-### Patch Changes
-
-- [`8f1d275b`](https://github.com/lemonmade/quilt/commit/8f1d275b6de0abbc6f61bcd5401555f6480eb474) Thanks [@lemonmade](https://github.com/lemonmade)! - Remove need for @babel/runtime peer dependency
-
-## 0.3.43
-
-### Patch Changes
-
-- [#478](https://github.com/lemonmade/quilt/pull/478) [`a2adeba4`](https://github.com/lemonmade/quilt/commit/a2adeba4b55dca0ff2fc6816090aaf128b9c0d60) Thanks [@lemonmade](https://github.com/lemonmade)! - Clean up initial HTML
-
-## 0.3.42
-
-### 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
-
-## 0.3.41
-
-### Patch Changes
-
-- [#470](https://github.com/lemonmade/quilt/pull/470) [`03e8da71`](https://github.com/lemonmade/quilt/commit/03e8da71c1c54b497f2b0d153a8414ae8e772666) Thanks [@lemonmade](https://github.com/lemonmade)! - Support suspense in react-server-render
-
-## 0.3.40
-
-### Patch Changes
-
-- [`98c6aa4b`](https://github.com/lemonmade/quilt/commit/98c6aa4b9b5f45cc947f25446e1f05e2145d64a7) Thanks [@lemonmade](https://github.com/lemonmade)! - Improve HTML customization
-
-## 0.3.39
-
-### Patch Changes
-
-- [`2da12a52`](https://github.com/lemonmade/quilt/commit/2da12a5211bc2f41dcf999b353616cb4aed07ed5) Thanks [@lemonmade](https://github.com/lemonmade)! - Prevent losing serializations after initial load
-
-## 0.3.38
-
-### Patch Changes
-
-- [`601c9ad3`](https://github.com/lemonmade/quilt/commit/601c9ad3b14d553c4b4f45db9520fe3d3f32e502) Thanks [@lemonmade](https://github.com/lemonmade)! - Fix serialize types for function arguments
-
-## 0.3.37
-
-### Patch Changes
-
-- [`aaa9178e`](https://github.com/lemonmade/quilt/commit/aaa9178e546bc4514e5581e586c4d3be2a708b1b) Thanks [@lemonmade](https://github.com/lemonmade)! - Fix serialization types
-
-* [`c5f3a832`](https://github.com/lemonmade/quilt/commit/c5f3a832d84f1f5694e552ea27e9951aec52e43b) Thanks [@lemonmade](https://github.com/lemonmade)! - Fix serialized hook to prefer function typings
-
-## 0.3.36
-
-### Patch Changes
-
-- [`293bb487`](https://github.com/lemonmade/quilt/commit/293bb487d085af798067f1ee78d480816669b0fa) Thanks [@lemonmade](https://github.com/lemonmade)! - Improve serializable types
-
-## 0.3.35
-
-### Patch Changes
-
-- [`a12c3576`](https://github.com/lemonmade/quilt/commit/a12c357693b173461f51a35fb7efdd0a9267e471) Thanks [@lemonmade](https://github.com/lemonmade)! - Fix more build issues
-
-## 0.3.34
-
-### Patch Changes
-
-- [`0629288e`](https://github.com/lemonmade/quilt/commit/0629288ee4ba2e2ccfd73fbb216c3559e1a5c77e) Thanks [@lemonmade](https://github.com/lemonmade)! - Fix missing package builds
-
-## 0.3.33
-
-### Patch Changes
-
-- [#364](https://github.com/lemonmade/quilt/pull/364) [`4dc1808a`](https://github.com/lemonmade/quilt/commit/4dc1808a86d15e821b218b528617430cbd8b5b48) Thanks [@lemonmade](https://github.com/lemonmade)! - Update to simplified Quilt config
-
-## 0.3.32
-
-### Patch Changes
-
-- [#359](https://github.com/lemonmade/quilt/pull/359) [`2eceac54`](https://github.com/lemonmade/quilt/commit/2eceac546fa3ee3e2c4d2887ab4a6a021acb52cd) Thanks [@lemonmade](https://github.com/lemonmade)! - Update TypeScript and ESLint to latest versions
-
-## 0.3.31
-
-### Patch Changes
-
-- [#331](https://github.com/lemonmade/quilt/pull/331) [`efc54f75`](https://github.com/lemonmade/quilt/commit/efc54f75cb29ec4143a8e52f577edff518014a6b) Thanks [@lemonmade](https://github.com/lemonmade)! - Fix React types in stricter package managers
-
-## 0.3.30
-
-### Patch Changes
-
-- [`281b36fd`](https://github.com/lemonmade/quilt/commit/281b36fd1dc6ea640da23e676b70673ce96d0080) Thanks [@lemonmade](https://github.com/lemonmade)! - Shorten URLs in HTML output
-
-## 0.3.29
-
-### Patch Changes
-
-- [`78fe1682`](https://github.com/lemonmade/quilt/commit/78fe1682e3f258ffca719c7eaaeeac05031dfa80) Thanks [@lemonmade](https://github.com/lemonmade)! - Simplify craft and sewing-kit
-
-## 0.3.28
-
-### Patch Changes
-
-- [`65db3731`](https://github.com/lemonmade/quilt/commit/65db37312192507643bafa672a29d8e63cce823f) Thanks [@lemonmade](https://github.com/lemonmade)! - Force another version bump
-
-## 0.3.27
-
-### Patch Changes
-
-- [`0735184`](https://github.com/lemonmade/quilt/commit/073518430d0fcabab7a2db9c76f8a69dac1fdea5) Thanks [@lemonmade](https://github.com/lemonmade)! - Publish new latest versions
-
-## 0.3.26
-
-### Patch Changes
-
-- [#203](https://github.com/lemonmade/quilt/pull/203) [`2a5063f`](https://github.com/lemonmade/quilt/commit/2a5063fe8e949eaa7829dd5685901b67a06c09c8) Thanks [@lemonmade](https://github.com/lemonmade)! - Fix test files being included in TypeScript output
-
-## 0.3.25
-
-### Patch Changes
-
-- [`937a890`](https://github.com/lemonmade/quilt/commit/937a89009924a7b1d9e2a102028efd97928396e3) Thanks [@lemonmade](https://github.com/lemonmade)! - Improve base TypeScript preset
-
-## 0.3.24
-
-### Patch Changes
-
-- [`8729c01`](https://github.com/lemonmade/quilt/commit/8729c01f7ef3e3c69844b521d5e5d3d915412551) Thanks [@lemonmade](https://github.com/lemonmade)! - Allow fetch-only use of `useSerialized()` hook
-
-## 0.3.23
-
-### Patch Changes
-
-- [`c58ca94`](https://github.com/lemonmade/quilt/commit/c58ca9468f24c1cc193d67f56692e07e71e918ab) Thanks [@lemonmade](https://github.com/lemonmade)! - Add `Serialize` component
-
-## 0.3.22
-
-### Patch Changes
-
-- [#190](https://github.com/lemonmade/quilt/pull/190) [`9bf454a`](https://github.com/lemonmade/quilt/commit/9bf454aaefc7ac6b85060fc5493b6b3ee4e2b526) Thanks [@lemonmade](https://github.com/lemonmade)! - Add easy environment variables
-
-## 0.3.21
-
-### Patch Changes
-
-- [`de03adb`](https://github.com/lemonmade/quilt/commit/de03adb1c102f88a0815b461932b66963072b85f) Thanks [@lemonmade](https://github.com/lemonmade)! - Fix serializing primitive types with useSerialized()
-
-## 0.3.20
-
-### Patch Changes
-
-- [#185](https://github.com/lemonmade/quilt/pull/185) [`3b9a758`](https://github.com/lemonmade/quilt/commit/3b9a758c5703aa63b93a736e33f88a3bfa393fb8) Thanks [@lemonmade](https://github.com/lemonmade)! - Improve package entry declarations
-
-## 0.3.19
-
-### Patch Changes
-
-- [`917ea19`](https://github.com/lemonmade/quilt/commit/917ea19edbd8ad210675b11ef7f2ebe0c33e0b3e) Thanks [@lemonmade](https://github.com/lemonmade)! - Fixed dependencies to support stricter pnpm-based project
diff --git a/packages/react-html/README.md b/packages/react-html/README.md
deleted file mode 100644
index 4aceff266..000000000
--- a/packages/react-html/README.md
+++ /dev/null
@@ -1,377 +0,0 @@
-# `@quilted/react-html`
-
-This library provides components and hooks for interacting with the HTML document. For a full overview of Quilt’s support for HTML, you can read the [HTML guide](../../documentation/features/html.md).
-
-## Getting the library
-
-> **Note:** `@quilted/quilt/html` re-exports the hooks and components from this library, and automatically applies the results during server-side rendering. If you already have `@quilted/quilt`, you don’t need to install this library.
-
-Install this library as a dependency by running the following command:
-
-```zsh
-yarn add @quilted/react-html
-```
-
-## Using the library
-
-### Configuring server-side rendering
-
-This library lets you interact with HTML from within a React app. This can be useful for an app that is only rendered on the client, but it’s great when paired with server-side rendering. To include the attributes and elements your app declares in your HTML response, you’ll need to do a bit of work to configure server-side rendering.
-
-> **Note:** if you are using Quilt’s [automatic server-side rendering feature](../../documentation/features/server-rendering.md), this work is already done for you. You can skip on to the next sections, where you’ll learn how to manipulate the HTML document from your React app.
-
-TODO
-
-### Configuring client-side updates
-
-> **Note:** if you are using Quilt’s [`App` component](TODO), this work is already done for you. You can skip on to the next sections, where you’ll learn how to manipulate the HTML document from your React app.
-
-A number of components and hooks from this library, including ``, ``, and ``, can update the HTML document client-side. These components rely on a central batching mechanism to coordinate their changes to the DOM. To kick off those batch updates, you need to call the `useHTMLUpdater()` hook from this library somewhere in your application:
-
-```tsx
-import {useHTMLUpdater} from '@quilted/react-html';
-// also available from '@quilted/quilt/html'
-
-export function App({user}: {user: string}) {
- useHTMLUpdater();
-
- return ;
-}
-```
-
-### Adding content to the ``
-
-You often need to add elements to the `` of your page. Many aspects of the browser’s behavior can be influenced through `` tags, and metadata for search engine optimization (SEO) are commonly placed in this part of the document. Quilt provides a collection of hooks and components that you can use in any component of your app to add these special tags.
-
-#### ``
-
-To add a custom [document title](https://developer.mozilla.org/en-US/docs/Web/API/Document/title), you can use the `useTitle` hook, or the `Title` component:
-
-```tsx
-import {useTitle, Title} from '@quilted/react-html';
-// also available from '@quilted/quilt/html'
-
-export function App({user}: {user: string}) {
- useTitle(`Welcome, ${user}!`);
-
- // or...
-
- return Welcome, {user}!;
-}
-```
-
-#### ``
-
-To add additional [`` tags](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta) to the ``, you can use the `useMeta` hook, or the `Meta` component:
-
-```tsx
-import {useMeta, Meta} from '@quilted/react-html';
-// also available from '@quilted/quilt/html'
-
-export function App() {
- useMeta({name: 'description', content: 'An app for doing fun stuff!'});
-
- // or...
-
- return ;
-}
-```
-
-Quilt also comes with a few components that provide more tailored APIs for common `` tags. The `useViewport` hook (or the `Viewport` component) can be used to set the [viewport `` tag](https://developer.mozilla.org/en-US/docs/Web/HTML/Viewport_meta_tag), and the `useThemeColor` hook (or the `ThemeColor` component) can be used to set the [`theme-color` `` tag](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta/name/theme-color):
-
-```tsx
-import {
- useViewport,
- useThemeColor,
- Viewport,
- ThemeColor,
-} from '@quilted/react-html';
-// also available from '@quilted/quilt/html'
-
-export function App() {
- useViewport({cover: true});
- useThemeColor('#BADA55');
-
- // or...
-
- return (
- <>
-
-
- >
- );
-}
-```
-
-The `useSearchRobots` hook and `SearchRobots` component can be used to set the [`robots` `` tag](https://developers.google.com/search/docs/advanced/robots/robots_meta_tag)
-
-```tsx
-import {useSearchRobots, SearchRobots} from '@quilted/react-html';
-// also available from '@quilted/quilt/html'
-
-export function App() {
- useSearchRobots({
- translate: false,
- snippet: {maxLength: 20},
- imagePreviews: {maxSize: 'large'},
- });
-
- // or...
-
- return (
-
- );
-}
-```
-
-#### ``
-
-To add additional [`` tags](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/link) to the ``, you can use the `useLink` hook, or the `Link` component:
-
-```tsx
-import {useLink, Link} from '@quilted/react-html';
-// also available from '@quilted/quilt/html'
-
-export function App() {
- useLink({
- rel: 'apple-touch-icon-precomposed',
- href: 'apple-icon-114.png',
- sizes: '114x114',
- type: 'image/png',
- });
-
- // or...
-
- return (
-
- );
-}
-```
-
-Like with `` tags, Quilt provides a convenience components for interacting with common uses of `` tags. You can use the `useFavicon` hook (or `Favicon` component) to set the [favicon](https://developer.mozilla.org/en-US/docs/Glossary/Favicon):
-
-```tsx
-import {useFavicon, Favicon} from '@quilted/react-html';
-// also available from '@quilted/quilt/html'
-
-export function App() {
- useFavicon('https://fav.farm/💩');
-
- // or...
-
- return ;
-}
-```
-
-The `Favicon` component comes with a few useful shortcuts for special options:
-
-```tsx
-import {Favicon} from '@quilted/react-html';
-
-export function BlankFavicon() {
- // Provides a completely empty image, which prevents browsers from trying
- // to make a request to your backend for a favicon.
- return ;
-}
-
-export function EmojiFavicon() {
- // Uses the emoji as a favicon by providing it as an inline SVG image.
- // Hat tip to Lea Verou! https://twitter.com/LeaVerou/status/1241619866475474946
- return ;
-}
-```
-
-If you use either of these special props, you **must** include the `data:` source in your [content security policy’s `img-src`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/img-src), as these props use an inline image provided as a data URI.
-
-You can use the `useAlternateUrl` hook or the `Alternate` component in order to specify an alternate URL for this page. You can pass `canonical: true` for these declarations to indicate that this is the [canonical URL of the page](https://developers.google.com/search/docs/advanced/crawling/consolidate-duplicate-urls), or you can pass the `locale` of the alternate page as a string to create a mapping between [the same content in multiple languages](https://developers.google.com/search/docs/advanced/crawling/localized-versions).
-
-```tsx
-import {useAlternateUrl, Alternate} from '@quilted/react-html';
-// also available from '@quilted/quilt/html'
-
-export function App() {
- // When specifying locale alternates, make sure to also specify
- // this page’s locale!
-
- useAlternateUrl('https://en.my-site.com', {locale: 'en'});
- useAlternateUrl('https://fr.my-site.com', {locale: 'fr'});
-
- // or...
-
- return (
- <>
-
-
- >
- );
-}
-```
-
-### Setting attributes on elements outside your React application
-
-Some libraries require you to put attributes on “special” elements in the DOM, most commonly either on the root `` element, or the `` element. Quilt provides an `` component and `useHTMLAttributes` hook for applying props to the `` element from within your React app, and ``/ `useBodyAttributes` for applying props to the `` element:
-
-```tsx
-import {
- BodyAttributes,
- HTMLAttributes,
- useBodyAttributes,
- useHTMLAttributes,
-} from '@quilted/react-html';
-// also available from '@quilted/quilt/html'
-
-export function App() {
- useHTMLAttributes({lang: 'en'});
- useBodyAttributes({'data-page': 'my-page'});
-
- // or...
-
- return (
- <>
-
-
- >
- );
-}
-```
-
-### Serializing data from the server to the browser
-
-A common need in web applications is to reference some data that exists on the server. This could be data loaded from a database, or retrieved with a network call. However it is fetched, you typically use that data to impact the content you render — for example, to render a list of products fetched from a GraphQL API. If you want to render that content on the server, and then have it render on the client as well, the data will need to be sent from the server to the client.
-
-Quilt provides a solution to this problem that works for web applications by rendering the data into your HTML document in a process we refer to as “serialization”. Specifically, Quilt places the data you need in `` tags in the head of the document, serializing the data as JSON.
-
-To make use of this serialization system, you must first set up [server-side rendering](#configuring-server-side-rendering) for this library. Once this is in place, you can use the `useSerialized()` hook to reference values on the server that will be serialized for access on the client.
-
-This hook takes two arguments. The first argument is a string, which acts as a unique identifier for this serialized data. Quilt uses this identifier to connect the data you retrieve on the server to the data that is serialized for the client.
-
-The second argument can be one of the following:
-
-- A serializable value (basically, anything that can be converted to JSON). When you pass a serializable value directly, it is used as the return result of the hook on the server, serialized to the client, and used as the return value of the hook on the client, too.
-- A function that returns a serializable value. This function will be run as a [deferred server action](./TODO), which means that it will run after server rendering has completed, but before the HTML response is sent to the browser. The result of calling this function is saved as a serialization and will be used as the value on the client, but for server-rendering, the initial return result of this hook is `undefined`, as no value is available yet. This version of the hook is ideal for things like caches, where you may accumulate a collection of data through rendering your app, and you want to make that cache available on the client to “hydrate” the cache.
-
-In either case, the [browser build](../../documentation/features/builds/app/browser.md) will remove the second argument entirely, as it is never used on the client — the serialized data in the HTML is used in its place.
-
-Let’s take a look at a few concrete examples to understand this serialization technique a little better.
-
-Imagine you had an environment variable that is available only to your server, and you want to use that value to render part of your application. Maybe you deploy your application to different geographies, and the current runtime’s geography is needed to determine the URL to use for some links on the page. Reading from `process.env` (assuming this your application will run in a Node.js environment) will work on the client, but you’ll need `useSerialized` to make that same value available on the client:
-
-```tsx
-import {useSerialized} from '@quilted/react-html';
-// also available from '@quilted/quilt/html'
-
-export function App() {
- const region = useSerialized('region', process.env.DEPLOY_REGION);
-
- return (
-
- Visit third-party
-
- );
-}
-```
-
-Now let’s image we are building an abstraction over `fetch`. We want this abstraction to let us make network requests on the server, and have the results of those requests be available synchronously for the client’s first render. We’ll ignore for a moment how we would perform the actual network requests ([`@quilted/react-server-render`](../react-server-render) would play a crucial role, though!). We’ll assume that we have an object like this one that is provided through context, which acts as a cache for previously-executed queries:
-
-```ts
-import {createContext} from 'react';
-
-type Data = any;
-
-export class FetchCache {
- constructor(initialResults?: Record);
- extract(): Record;
-}
-
-export const FetchContext = createContext(null);
-```
-
-On the server, there will be no initial cache result; those results will be “filled in” by the initial calls made during server-side rendering. We want the `initialResults` fetched by the server to be serialized for the client so that we can construct the cache with that object.
-
-In this case, we’ll use the function form for the second argument of `useSerialized`. This function runs after server rendering is finished, which is required given that we need all the server fetches to be finished before the cache is extracted.
-
-```tsx
-import {useMemo} from 'react';
-import {useSerialized} from '@quilted/react-html';
-// also available from '@quilted/quilt/html'
-
-export function App() {
- const serializedCache = useSerialized('fetch', () => fetchCache.extract());
- const fetchCache = useMemo(
- () => new FetchCache(serializedCache),
- [serializedCache],
- );
-
- return (
-
-
-
- );
-}
-```
-
-If you need to read a serialized value from outside of a React context, you can use the `getSerialized` function, which attempts to read serializations directly from the DOM:
-
-```ts
-import {getSerialized} from '@quilted/react-html';
-// also available from '@quilted/quilt/html'
-
-const fetchCache = getSerialized('fetch');
-doSomethingWithFetchCache(fetchCache);
-```
-
-### Deferred hydration
-
-Imagine that there is a complex piece of UI that is rendered by your application. Depending on the rest of the contents of the page, this component may or may not be visible in the viewport.
-
-In this situation, we’d ideally like to still server-render the component; if it’s in the initial viewport, we’d like to avoid it rendering after the rest of the content of the page. However, we only want to load the JavaScript for the component when we know for certain it _is_ in the viewport (which we can only determine on the client). If we determine it is in the viewport, we’d like to continue to use the server-rendered markup until the code for that component has loaded.
-
-This pattern is sometimes called “deferred hydration”, and Quilt provides a special `Hydrate` component that allows you to implement the kinds of behavior described above.
-
-This component accepts `children`, which should be the contents you want to defer hydration for.
-
-It also accepts an `id` prop, which is used to identify the hydrated content on the client.
-
-Finally, this component requires you to pass a boolean `render` prop. When `render` is `true`, the contents you passed as `children` are rendered by this component. When it is `false`, the `Hydrator` component will instead attempt to use the HTML for the hydration matching the passed `id`.
-
-A basic implementation of the “only load the component when it is in the viewport” behavior described above would look like this:
-
-```tsx
-import {Hydrator} from '@quilted/react-html';
-
-function MyAsyncComponent() {
- // We are ignoring how we would synchronously load the component
- // on the server — assume this hook will return a defined `Component`
- // on the server, but will initially not return it on the client.
- const [Component, load] = useMaybeAsyncComponent();
-
- return (
-
-
- {Component ? : null}
-
-
- );
-}
-```
-
-The example above is ignoring how `Component` could be loaded synchronously on the server (which is needed so that the initial HTML payload includes its rendered output), and does not address some of the trickier parts of deferred hydration (like ensuring that styles are referenced in the HTML, even if the JavaScript will be loaded asynchronously). Quilt provides a powerful [async component library](../react-async) that implements the deferred hydration pattern without you needing to worry about these complexities. The `createAsyncComponent()` function from that library uses `Hydrator` under the hood, so you probably don’t ever need to use `Hydrator` directly.
-
-### Testing
-
-TODO
diff --git a/packages/react-html/configuration/rollup.config.js b/packages/react-html/configuration/rollup.config.js
deleted file mode 100644
index ad2841ea7..000000000
--- a/packages/react-html/configuration/rollup.config.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import {quiltPackage} from '@quilted/rollup/package';
-
-export default quiltPackage();
diff --git a/packages/react-html/source/components.ts b/packages/react-html/source/components.ts
deleted file mode 100644
index b7d933717..000000000
--- a/packages/react-html/source/components.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-export {Alternate} from './components/Alternate.tsx';
-export {BodyAttributes} from './components/BodyAttributes.tsx';
-export {HeadScript} from './components/HeadScript.tsx';
-export {HeadStyle} from './components/HeadStyle.tsx';
-export {HTMLAttributes} from './components/HTMLAttributes.tsx';
-export {Link} from './components/Link.tsx';
-export {Meta} from './components/Meta.tsx';
-export {ThemeColor} from './components/ThemeColor.tsx';
-export {Title} from './components/Title.tsx';
-export {SearchRobots} from './components/SearchRobots.tsx';
-export {Serialize} from './components/Serialize.tsx';
-export {Viewport} from './components/Viewport.tsx';
-export {Favicon} from './components/Favicon.tsx';
diff --git a/packages/react-html/source/components/HeadScript.tsx b/packages/react-html/source/components/HeadScript.tsx
deleted file mode 100644
index 73df4e0d7..000000000
--- a/packages/react-html/source/components/HeadScript.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-import {useHeadScript} from '../hooks/head-script.ts';
-
-/**
- * Adds a `