diff --git a/styled-components/.eslintrc.js b/styled-components/.eslintrc.js index 2061cd22..3f74a67d 100644 --- a/styled-components/.eslintrc.js +++ b/styled-components/.eslintrc.js @@ -1,4 +1,84 @@ +/** + * This is intended to be a basic starting point for linting in your app. + * It relies on recommended configs out of the box for simplicity, but you can + * and should modify this configuration to best suit your team's needs. + */ + /** @type {import('eslint').Linter.Config} */ module.exports = { - extends: ["@remix-run/eslint-config", "@remix-run/eslint-config/node"], -}; + root: true, + parserOptions: { + ecmaVersion: "latest", + sourceType: "module", + ecmaFeatures: { + jsx: true, + }, + }, + env: { + browser: true, + commonjs: true, + es6: true, + }, + ignorePatterns: ["!**/.server", "!**/.client"], + + // Base config + extends: ["eslint:recommended"], + + overrides: [ + // React + { + files: ["**/*.{js,jsx,ts,tsx}"], + plugins: ["react", "jsx-a11y"], + extends: [ + "plugin:react/recommended", + "plugin:react/jsx-runtime", + "plugin:react-hooks/recommended", + "plugin:jsx-a11y/recommended", + ], + settings: { + react: { + version: "detect", + }, + formComponents: ["Form"], + linkComponents: [ + { name: "Link", linkAttribute: "to" }, + { name: "NavLink", linkAttribute: "to" }, + ], + "import/resolver": { + typescript: {}, + }, + }, + }, + + // Typescript + { + files: ["**/*.{ts,tsx}"], + plugins: ["@typescript-eslint", "import"], + parser: "@typescript-eslint/parser", + settings: { + "import/internal-regex": "^~/", + "import/resolver": { + node: { + extensions: [".ts", ".tsx"], + }, + typescript: { + alwaysTryTypes: true, + }, + }, + }, + extends: [ + "plugin:@typescript-eslint/recommended", + "plugin:import/recommended", + "plugin:import/typescript", + ], + }, + + // Node + { + files: [".eslintrc.cjs"], + env: { + node: true, + }, + }, + ], +}; \ No newline at end of file diff --git a/styled-components/app/entry.server.tsx b/styled-components/app/entry.server.tsx index 522730b3..50d35c9c 100644 --- a/styled-components/app/entry.server.tsx +++ b/styled-components/app/entry.server.tsx @@ -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( - , - ), - ); - const styles = sheet.getStyleTags(); + const { pipe, abort } = renderToPipeableStream( + + + , + { + [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("" + 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() + ); + this.push(renderedHtml); + + callback(); + }, + }); diff --git a/styled-components/app/root.tsx b/styled-components/app/root.tsx index 56e4f56a..9ee3e071 100644 --- a/styled-components/app/root.tsx +++ b/styled-components/app/root.tsx @@ -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 ( @@ -20,6 +22,7 @@ export default function App() { + {/* __STYLES__ will be replaced in entry-server.tsx */} {typeof document === "undefined" ? "__STYLES__" : null} diff --git a/styled-components/app/routes/_boundary.tsx b/styled-components/app/routes/_boundary.tsx index c9f5d55d..c059b71a 100644 --- a/styled-components/app/routes/_boundary.tsx +++ b/styled-components/app/routes/_boundary.tsx @@ -1,30 +1,35 @@ -import { Outlet, useCatch } from "@remix-run/react"; - +import { isRouteErrorResponse, Outlet, useRouteError } from "@remix-run/react"; import { Box } from "~/components/Box"; export default function Boundary() { return ; } -export function CatchBoundary() { - const caught = useCatch(); +export function ErrorBoundary() { + const error = useRouteError(); - return ( - -

Catch Boundary

-

- {caught.status} {caught.statusText} -

-
- ); -} + if (isRouteErrorResponse(error)) { + return ( + +

Catch Boundary

+

+ {error.status} {error.statusText} +

+
+ ); + } + + let message, stack; + if (error instanceof Error) { + message = error.message; + stack = error.stack; + } -export function ErrorBoundary({ error }: { error: Error }) { return (

Error Boundary

-

{error.message}

-
{error.stack}
+

{message}

+
{stack}
); -} +} \ No newline at end of file diff --git a/styled-components/app/styles-context.tsx b/styled-components/app/styles-context.tsx deleted file mode 100644 index 1010d084..00000000 --- a/styled-components/app/styles-context.tsx +++ /dev/null @@ -1,4 +0,0 @@ -import * as React from "react"; -const StylesContext = React.createContext(null); -export const StylesProvider = StylesContext.Provider; -export const useStyles = () => React.useContext(StylesContext); diff --git a/styled-components/components/src/Box.tsx b/styled-components/components/src/Box.tsx index 856bb3fb..220b7169 100644 --- a/styled-components/components/src/Box.tsx +++ b/styled-components/components/src/Box.tsx @@ -1,4 +1,4 @@ -import styled from "styled-components"; +import { styled } from "styled-components"; export const Box = styled("div")` font-family: system-ui, sans-serif; diff --git a/styled-components/components/tsconfig.json b/styled-components/components/tsconfig.json index 40bbe784..2279024c 100644 --- a/styled-components/components/tsconfig.json +++ b/styled-components/components/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "../tsconfig.json", "include": ["**/*.ts", "**/*.tsx"], + "exclude": [], "compilerOptions": { "outDir": "../app/components", diff --git a/styled-components/package.json b/styled-components/package.json index 35ef4d72..52044d66 100644 --- a/styled-components/package.json +++ b/styled-components/package.json @@ -1,6 +1,7 @@ { "private": true, "sideEffects": false, + "type": "module", "scripts": { "build": "run-s \"build:*\"", "build:components": "run-p \"build:components:*\"", @@ -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" } } diff --git a/styled-components/remix.config.js b/styled-components/remix.config.js deleted file mode 100644 index ca00ba94..00000000 --- a/styled-components/remix.config.js +++ /dev/null @@ -1,11 +0,0 @@ -/** @type {import('@remix-run/dev').AppConfig} */ -module.exports = { - future: { - v2_routeConvention: true, - }, - ignoredRouteFiles: ["**/.*"], - // appDirectory: "app", - // assetsBuildDirectory: "public/build", - // publicPath: "/build/", - // serverBuildPath: "build/index.js", -}; diff --git a/styled-components/remix.env.d.ts b/styled-components/remix.env.d.ts deleted file mode 100644 index dcf8c45e..00000000 --- a/styled-components/remix.env.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -/// -/// diff --git a/styled-components/tsconfig.json b/styled-components/tsconfig.json index 20f8a386..e5150036 100644 --- a/styled-components/tsconfig.json +++ b/styled-components/tsconfig.json @@ -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 + } } } diff --git a/styled-components/vite.config.ts b/styled-components/vite.config.ts new file mode 100644 index 00000000..e4e8cefc --- /dev/null +++ b/styled-components/vite.config.ts @@ -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(), + ], +});