Skip to content

feat: styled-components & streaming #588

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions styled-components/app/entry.client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* By default, Remix will handle hydrating your app on the client for you.
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
* For more information, see https://remix.run/file-conventions/entry.client
*/

import { RemixBrowser } from "@remix-run/react";
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";

startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<RemixBrowser />
</StrictMode>
);
});
99 changes: 84 additions & 15 deletions styled-components/app/entry.server.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,99 @@
import { PassThrough, Transform } from "stream";

import type { AppLoadContext, EntryContext } from "@remix-run/node";
import { createReadableStreamFromReadable } from "@remix-run/node";
import { RemixServer } from "@remix-run/react";
import { renderToString } from "react-dom/server";
import { ServerStyleSheet } from "styled-components";
import { renderToPipeableStream } from "react-dom/server";
import { ServerStyleSheet, StyleSheetManager } from "styled-components";
import { isbot } from "isbot";

// Reject/cancel all pending promises after 5 seconds
export const streamTimeout = 15000;

export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext,
loadContext: AppLoadContext,
// This is ignored so we can keep it in the template for visibility. Feel
// free to delete this parameter in your app if you're not using it!
// eslint-disable-next-line @typescript-eslint/no-unused-vars
loadContext: AppLoadContext
) {
const sheet = new ServerStyleSheet();
const styleSheet = new ServerStyleSheet();
const decoder = new TextDecoder("utf-8");
// Stream interceptor in order to inject additional HTML on the fly
const transformer = transformStream({ decoder, styleSheet });

const callbackName = isbot(request.headers.get("user-agent"))
? "onAllReady"
: "onShellReady";

// eslint-disable-next-line no-async-promise-executor
return new Promise(async (resolve, reject) => {
let didError = false;

let markup = renderToString(
sheet.collectStyles(
<RemixServer context={remixContext} url={request.url} />,
),
);
const styles = sheet.getStyleTags();
const { pipe, abort } = renderToPipeableStream(
<StyleSheetManager sheet={styleSheet.instance}>
<RemixServer context={remixContext} url={request.url} />
</StyleSheetManager>,
{
[callbackName]: () => {
const body = new PassThrough();
responseHeaders.set("Content-Type", "text/html");

markup = markup.replace("__STYLES__", styles);
pipe(transformer);
transformer.pipe(body);

responseHeaders.set("Content-Type", "text/html");
resolve(
new Response(createReadableStreamFromReadable(body), {
status: didError ? 500 : responseStatusCode,
headers: responseHeaders,
})
);
},
onShellError: (err: unknown) => {
reject(err);
},
onError: () => {
didError = true;
},
}
);

return new Response("<!DOCTYPE html>" + markup, {
status: responseStatusCode,
headers: responseHeaders,
// Automatically timeout the React renderer after 6 seconds, which ensures
// React has enough time to flush down the rejected boundary contents
setTimeout(abort, streamTimeout + 1000);
});
}

/**
* Returns a Transform stream that injects styled-components styles into streamed HTML.
* - Replaces `__STYLES__` with styled-components CSS.
*/
const transformStream = ({
decoder,
styleSheet,
}: {
decoder: TextDecoder;
styleSheet: ServerStyleSheet;
}) =>
new Transform({
objectMode: true,
flush(callback) {
callback();
},
transform(chunk, encoding, callback) {
let renderedHtml =
chunk instanceof Uint8Array
? decoder.decode(chunk, { stream: true })
: chunk.toString(encoding || "utf8");
renderedHtml = renderedHtml.replace(
"__STYLES__",
styleSheet.getStyleTags()
);
Comment on lines +88 to +94

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't it possible for the __STYLES__ string to be truncated across two different chunks?
In that case, the styles would not get replaced.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in theory this could be true, you're right. unfortunately i don't understand node streaming and how the chunks are cut in depth. however, we have been using this approach in production for 2 weeks (first with remix v2 and now with react-router v7) and have never noticed this side effect.

Copy link

@arcastro arcastro May 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In my own project, I ended up using

Which handles chunk boundaries correctly, but I'd lean against including that additional dependency in this pull request.

I do think the edge case should be handled correctly, but I don't have any alternative suggestions as to how, other than reimplementing similar logic. But that seems like overkill maybe 🤷‍♂️

this.push(renderedHtml);

callback();
},
});
12 changes: 7 additions & 5 deletions styled-components/app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ import {
ScrollRestoration,
} from "@remix-run/react";

export const meta: MetaFunction = () => ({
charset: "utf-8",
title: "New Remix App",
viewport: "width=device-width,initial-scale=1",
});
export const meta: MetaFunction = () => [
{
charset: "utf-8",
title: "New Remix App",
viewport: "width=device-width,initial-scale=1",
},
];

export default function App() {
return (
Expand Down
43 changes: 23 additions & 20 deletions styled-components/app/routes/_boundary.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,33 @@
import { Outlet, useCatch } from "@remix-run/react";
import { isRouteErrorResponse, useRouteError } from "@remix-run/react";

import { Box } from "~/components/Box";

export default function Boundary() {
return <Outlet />;
}
export function ErrorBoundary() {
const error = useRouteError();

export function CatchBoundary() {
const caught = useCatch();
if (isRouteErrorResponse(error)) {
return (
<Box>
<h1>Catch Boundary</h1>
<p>
{error.status} {error.statusText}
</p>
</Box>
);
}

return (
<Box>
<h1>Catch Boundary</h1>
<p>
{caught.status} {caught.statusText}
</p>
</Box>
);
}
let errorMessage = "Unknown error";
let errorStatus = 500;
if (error instanceof Error) {
errorMessage = error.message;
}

export function ErrorBoundary({ error }: { error: Error }) {
return (
<Box>
<h1>Error Boundary</h1>
<p>{error.message}</p>
<pre>{error.stack}</pre>
<h1>Error Boundary</h1>
<p>
{errorStatus} {errorMessage}
</p>
</Box>
);
}
}
2 changes: 1 addition & 1 deletion styled-components/app/styles-context.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import * as React from "react";
const StylesContext = React.createContext<null | React.ReactNode>(null);
export const StylesProvider = StylesContext.Provider;
export const useStyles = () => React.useContext(StylesContext);
export const useStyles = () => React.useContext(StylesContext);
1 change: 1 addition & 0 deletions styled-components/components/Box.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export declare const Box: import("styled-components/dist/types").IStyledComponentBase<"web", import("styled-components").FastOmit<import("react").ClassAttributes<HTMLDivElement> & import("react").HTMLAttributes<HTMLDivElement>, never>> & string;
3 changes: 2 additions & 1 deletion styled-components/components/src/Box.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import styled from "styled-components";
import { styled } from "styled-components";

export const Box = styled("div")`
font-family: system-ui, sans-serif;
line-height: 1.8;
background-color: red;
`;
1 change: 1 addition & 0 deletions styled-components/components/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"extends": "../tsconfig.json",
"include": ["**/*.ts", "**/*.tsx"],
"exclude": [],
"compilerOptions": {
"outDir": "../app/components",

Expand Down
49 changes: 30 additions & 19 deletions styled-components/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"private": true,
"sideEffects": false,
"type": "module",
"scripts": {
"build": "run-s \"build:*\"",
"build:components": "run-p \"build:components:*\"",
Expand All @@ -14,33 +15,43 @@
"dev:remix": "remix dev",
"generate:components:src": "babel components/src --out-dir app/components --extensions .ts,.js,.tsx,.jsx,.cjs,.mjs",
"generate:components:types": "tsc --project components/tsconfig.json",
"start": "remix-serve build",
"start": "remix-serve ./build/index.js",
"typecheck": "tsc"
},
"dependencies": {
"@remix-run/node": "^1.19.3",
"@remix-run/react": "^1.19.3",
"@remix-run/serve": "^1.19.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"styled-components": "^5.3.3"
"@remix-run/node": "^2.16.6",
"@remix-run/react": "^2.16.6",
"@remix-run/serve": "^2.16.6",
"isbot": "^4.1.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"styled-components": "^6.1.18"
},
"devDependencies": {
"@babel/cli": "^7.22.10",
"@babel/core": "^7.22.10",
"@babel/preset-react": "^7.22.5",
"@babel/preset-typescript": "^7.22.5",
"@remix-run/dev": "^1.19.3",
"@remix-run/eslint-config": "^1.19.3",
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7",
"@types/styled-components": "^5.1.24",
"@babel/cli": "^7.27.2",
"@babel/core": "^7.27.1",
"@babel/preset-react": "^7.27.1",
"@babel/preset-typescript": "^7.27.1",
"@remix-run/dev": "^2.16.6",
"@types/react": "^19.1.4",
"@types/react-dom": "^19.1.5",
"@types/styled-components": "^5.1.34",
"@typescript-eslint/eslint-plugin": "^6.7.4",
"@typescript-eslint/parser": "^6.7.4",
"autoprefixer": "^10.4.19",
"babel-plugin-styled-components": "^2.1.4",
"eslint": "^8.27.0",
"eslint": "^8.38.0",
"eslint-import-resolver-typescript": "^3.6.1",
"eslint-plugin-import": "^2.28.1",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"npm-run-all": "^4.1.5",
"typescript": "^4.8.4"
"typescript": "^5.1.6",
"vite": "^6.0.0",
"vite-tsconfig-paths": "^4.2.1"
},
"engines": {
"node": ">=14.0.0"
"node": ">=20.0.0"
}
}
11 changes: 0 additions & 11 deletions styled-components/remix.config.js

This file was deleted.

2 changes: 0 additions & 2 deletions styled-components/remix.env.d.ts

This file was deleted.

16 changes: 8 additions & 8 deletions styled-components/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
{
"include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"],
"include": ["**/*.ts", "**/*.tsx"],
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2019"],
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"types": ["@remix-run/node", "vite/client"],
"isolatedModules": true,
"esModuleInterop": true,
"jsx": "react-jsx",
"moduleResolution": "node",
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"target": "ES2019",
"target": "ES2022",
"strict": true,
"allowJs": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"paths": {
"~/*": ["./app/*"]
},

// Remix takes care of building everything in `remix build`.
"noEmit": true
}
}
}
24 changes: 24 additions & 0 deletions styled-components/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { vitePlugin as remix } from "@remix-run/dev";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";

declare module "@remix-run/node" {
interface Future {
v3_singleFetch: true;
}
}

export default defineConfig({
plugins: [
remix({
future: {
v3_fetcherPersist: true,
v3_relativeSplatPath: true,
v3_throwAbortReason: true,
v3_singleFetch: true,
v3_lazyRouteDiscovery: true,
},
}),
tsconfigPaths(),
],
});