From 1035a69ac02e6d546e81c6072d7fa5ebe2d92871 Mon Sep 17 00:00:00 2001 From: Mohamed-Elshesheny Date: Mon, 20 Apr 2026 19:08:17 +0200 Subject: [PATCH 1/3] feat(ci): add CI workflow for linting, type-checking, and building; add Docker healthcheck to Dockerfile --- .github/workflows/ci.yml | 79 +++++++++++++++++++++++++++++++++++ .github/workflows/release.yml | 3 ++ Dockerfile | 4 ++ 3 files changed, 86 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..72ea1db --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,79 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Lint, type-check, build + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' + + - name: Restore turbo cache + uses: actions/cache@v4 + with: + path: | + .turbo + node_modules/.cache/turbo + key: ${{ runner.os }}-turbo-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-turbo-${{ hashFiles('pnpm-lock.yaml') }}- + ${{ runner.os }}-turbo- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Lint + run: pnpm lint + + - name: Type-check + run: pnpm check-types + + - name: Build + run: pnpm build + + docker: + name: Docker stack health + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build images + run: docker compose build + + - name: Start stack and wait for healthchecks + run: docker compose up -d --wait --wait-timeout 180 + + - name: Dump compose state on failure + if: failure() + run: | + docker compose ps + docker compose logs --no-color + + - name: Tear down stack + if: always() + run: docker compose down -v diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6de4421..9d20860 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -52,6 +52,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Verify workspace (lint, types, build) + run: pnpm lint && pnpm check-types && pnpm build + - name: Configure Git user run: | git config --global user.name "github-actions[bot]" diff --git a/Dockerfile b/Dockerfile index 752c920..af86d67 100644 --- a/Dockerfile +++ b/Dockerfile @@ -116,4 +116,8 @@ RUN echo 'server { \ }' > /etc/nginx/conf.d/default.conf EXPOSE 80 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD wget -qO- http://localhost/ >/dev/null 2>&1 || exit 1 + CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file From 6cd48c3b47b4c563eb36d58d2a3be88bd27d308a Mon Sep 17 00:00:00 2001 From: Mohamed-Elshesheny Date: Wed, 22 Apr 2026 22:28:13 +0200 Subject: [PATCH 2/3] refactor(Dockerfile): streamline Dockerfile stages and improve healthcheck command --- Dockerfile | 246 ++++++++++++++++---------------- packages/lib/src/data/tree.ts | 11 +- packages/shared/src/debounce.ts | 2 +- 3 files changed, 133 insertions(+), 126 deletions(-) diff --git a/Dockerfile b/Dockerfile index af86d67..163004c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,123 +1,125 @@ -# ========================================== -# STAGE 1: Pruner -# ========================================== -FROM node:20-alpine AS pruner -RUN apk add --no-cache libc6-compat -WORKDIR /app -RUN npm install -g turbo -COPY . . -RUN turbo prune --scope=web --scope=@repo/backend --docker - -# ========================================== -# STAGE 2: Base & Builder -# ========================================== -FROM node:20-alpine AS builder -RUN apk add --no-cache libc6-compat sqlite -WORKDIR /app - -RUN corepack enable && corepack prepare pnpm@10.27.0 --activate - -# Copy pruned files -COPY --from=pruner /app/out/json/ . -COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml - -RUN pnpm install --frozen-lockfile - -COPY --from=pruner /app/out/full/ . - -ENV DB_FILE_NAME=file:/app/apps/backend/local.db - -# Web app environment variables (build-time) -ARG VITE_BACKEND_URL - -ENV VITE_BACKEND_URL=${VITE_BACKEND_URL:-} - - -RUN pnpm build --filter=web... --filter=@repo/backend... - -RUN pnpm --filter @repo/backend --prod deploy --legacy pruned-backend - -# ========================================== -# STAGE 3: Backend Runner -# ========================================== -FROM node:20-alpine AS backend -WORKDIR /app - -RUN apk add --no-cache sqlite libc6-compat - -RUN addgroup --system --gid 1001 nodejs && \ - adduser --system --uid 1001 nodejs - -COPY --from=builder /app/pruned-backend . -COPY --from=builder /app/apps/backend/dist ./dist -COPY --from=builder /app/apps/backend/drizzle ./drizzle -COPY --from=builder /app/apps/backend/src/scripts/run-migrations.mjs ./run-migrations.mjs - -RUN mkdir -p storage && chown -R nodejs:nodejs storage - -USER nodejs - -ENV NODE_ENV=production -ENV PORT=3000 -ENV DB_FILE_NAME=file:storage/local.db -ENV CLIENT_URL=http://localhost:5173 - -EXPOSE 3000 - -CMD ["sh", "-c", "node run-migrations.mjs && node dist/index.js"] - -# ========================================== -# STAGE 4: Web Runner (Nginx) -# ========================================== -FROM nginx:alpine AS web -WORKDIR /usr/share/nginx/html -RUN rm -rf ./* -COPY --from=builder /app/apps/web/dist . - -RUN echo 'server { \ - listen 80; \ - \ - # Enable Gzip compression for faster loading \ - gzip on; \ - gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; \ - \ - location / { \ - root /usr/share/nginx/html; \ - index index.html index.htm; \ - try_files $uri $uri/ /index.html; \ - } \ - \ - # Proxy API requests to the backend container \ - location /api/ { \ - proxy_pass http://backend:3000; \ - proxy_set_header Host $host; \ - proxy_set_header X-Real-IP $remote_addr; \ - } \ - \ - # Proxy storage requests to the backend container \ - location /storage/ { \ - proxy_pass http://backend:3000; \ - proxy_set_header Host $host; \ - proxy_set_header X-Real-IP $remote_addr; \ - client_max_body_size 10M; \ - } \ - \ - # Proxy WebSocket connections for Socket.io \ - location /socket.io/ { \ - proxy_pass http://backend:3000; \ - proxy_http_version 1.1; \ - proxy_set_header Upgrade $http_upgrade; \ - proxy_set_header Connection "upgrade"; \ - proxy_set_header Host $host; \ - proxy_set_header X-Real-IP $remote_addr; \ - proxy_read_timeout 86400; \ - proxy_send_timeout 86400; \ - } \ -}' > /etc/nginx/conf.d/default.conf - -EXPOSE 80 - -HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ - CMD wget -qO- http://localhost/ >/dev/null 2>&1 || exit 1 - +# ========================================== +# STAGE 1: Pruner +# ========================================== +FROM node:20-alpine AS pruner +RUN apk add --no-cache libc6-compat +WORKDIR /app +RUN npm install -g turbo +COPY . . +RUN turbo prune --scope=web --scope=@repo/backend --docker + +# ========================================== +# STAGE 2: Base & Builder +# ========================================== +FROM node:20-alpine AS builder +RUN apk add --no-cache libc6-compat sqlite +WORKDIR /app + +RUN corepack enable && corepack prepare pnpm@10.27.0 --activate + +# Copy pruned files +COPY --from=pruner /app/out/json/ . +COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml + +RUN pnpm install --frozen-lockfile + +COPY --from=pruner /app/out/full/ . + +ENV DB_FILE_NAME=file:/app/apps/backend/local.db + +# Web app environment variables (build-time) +ARG VITE_BACKEND_URL + +ENV VITE_BACKEND_URL=${VITE_BACKEND_URL:-} + + +RUN pnpm build --filter=web... --filter=@repo/backend... + +RUN pnpm --filter @repo/backend --prod deploy --legacy pruned-backend + +# ========================================== +# STAGE 3: Backend Runner +# ========================================== +FROM node:20-alpine AS backend +WORKDIR /app + +RUN apk add --no-cache sqlite libc6-compat + +RUN addgroup --system --gid 1001 nodejs && \ + adduser --system --uid 1001 nodejs + +COPY --from=builder /app/pruned-backend . +COPY --from=builder /app/apps/backend/dist ./dist +COPY --from=builder /app/apps/backend/drizzle ./drizzle +COPY --from=builder /app/apps/backend/src/scripts/run-migrations.mjs ./run-migrations.mjs + +RUN mkdir -p storage && chown -R nodejs:nodejs storage + +USER nodejs + +ENV NODE_ENV=production +ENV PORT=3000 +ENV DB_FILE_NAME=file:storage/local.db +ENV CLIENT_URL=http://localhost:5173 + +EXPOSE 3000 + +CMD ["sh", "-c", "node run-migrations.mjs && node dist/index.js"] + +# ========================================== +# STAGE 4: Web Runner (Nginx) +# ========================================== +FROM nginx:alpine AS web +WORKDIR /usr/share/nginx/html +RUN rm -rf ./* +COPY --from=builder /app/apps/web/dist . + +RUN cat <<'EOF' > /etc/nginx/conf.d/default.conf +server { + listen 80; + + # Enable Gzip compression for faster loading + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + + location / { + root /usr/share/nginx/html; + index index.html index.htm; + try_files $uri $uri/ /index.html; + } + + # Proxy API requests to the backend container + location /api/ { + proxy_pass http://backend:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + # Proxy storage requests to the backend container + location /storage/ { + proxy_pass http://backend:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + client_max_body_size 10M; + } + + # Proxy WebSocket connections for Socket.io + location /socket.io/ { + proxy_pass http://backend:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_read_timeout 86400; + proxy_send_timeout 86400; + } +} +EOF + +EXPOSE 80 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD wget -qO- http://127.0.0.1/ >/dev/null 2>&1 || exit 1 + CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/packages/lib/src/data/tree.ts b/packages/lib/src/data/tree.ts index fefbede..40cec27 100644 --- a/packages/lib/src/data/tree.ts +++ b/packages/lib/src/data/tree.ts @@ -9,7 +9,12 @@ export interface TreeNodeData { id: string; parentId?: string | null; position?: string | null; - [key: string]: any; + [key: string]: unknown; +} + +interface TreeNodeJson { + data: T; + children?: TreeNodeJson[]; } export interface SerializedTreeNode { @@ -147,10 +152,10 @@ export class TreeNode { /** * Creates a tree from a serialized object */ -export function jsonToTree(obj: any): TreeNode { +export function jsonToTree(obj: TreeNodeJson): TreeNode { const node = new TreeNode(obj.data as T); if (Array.isArray(obj.children)) { - obj.children.forEach((childObj: any) => { + obj.children.forEach((childObj) => { const childNode = jsonToTree(childObj); node.addChild(childNode); }); diff --git a/packages/shared/src/debounce.ts b/packages/shared/src/debounce.ts index 47b994b..c2c773e 100644 --- a/packages/shared/src/debounce.ts +++ b/packages/shared/src/debounce.ts @@ -4,7 +4,7 @@ */ 'use client'; -export const debounce = any>(func: F, waitFor: number) => { +export const debounce = unknown>(func: F, waitFor: number) => { let timeout: ReturnType | null = null; return (...args: Parameters): void => { From 6615f9b8035b16c66b72ccbec3cc1f4d6a369616 Mon Sep 17 00:00:00 2001 From: Ibrahim El-bastawisi Date: Wed, 22 Apr 2026 23:06:39 +0200 Subject: [PATCH 3/3] fix(eslint-config): set explicit React version to prevent compatibility issues with ESLint 10 --- packages/eslint-config/react-internal.js | 22 ++++++++++--------- packages/ui/src/components/color-picker.tsx | 11 +++++++--- packages/ui/src/components/dynamic-icon.tsx | 6 ++--- packages/ui/src/components/icon-picker.tsx | 1 + .../ui/src/components/image-file-input.tsx | 5 ++--- packages/ui/src/components/tree.tsx | 20 ++++++++--------- packages/ui/src/hooks/use-container-query.ts | 2 +- packages/ui/src/hooks/use-hash.ts | 2 +- packages/ui/src/hooks/use-local-storage.ts | 4 ++-- packages/ui/src/hooks/use-optimistic.ts | 2 +- packages/ui/src/hooks/use-throttle.ts | 5 ++++- packages/ui/src/theme/theme-provider.tsx | 5 ++++- 12 files changed, 49 insertions(+), 36 deletions(-) diff --git a/packages/eslint-config/react-internal.js b/packages/eslint-config/react-internal.js index 8d5dfd5..0fa5aa4 100644 --- a/packages/eslint-config/react-internal.js +++ b/packages/eslint-config/react-internal.js @@ -3,13 +3,13 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import js from "@eslint/js"; -import eslintConfigPrettier from "eslint-config-prettier"; -import tseslint from "typescript-eslint"; -import pluginReactHooks from "eslint-plugin-react-hooks"; -import pluginReact from "eslint-plugin-react"; -import globals from "globals"; -import { config as baseConfig } from "./base.js"; +import js from '@eslint/js'; +import eslintConfigPrettier from 'eslint-config-prettier'; +import tseslint from 'typescript-eslint'; +import pluginReactHooks from 'eslint-plugin-react-hooks'; +import pluginReact from 'eslint-plugin-react'; +import globals from 'globals'; +import { config as baseConfig } from './base.js'; /** * A custom ESLint configuration for libraries that use React. @@ -32,13 +32,15 @@ export const config = [ }, { plugins: { - "react-hooks": pluginReactHooks, + 'react-hooks': pluginReactHooks, }, - settings: { react: { version: "detect" } }, + // Use an explicit version (not "detect") so eslint-plugin-react does not call + // context.getFilename(), which was removed in ESLint 10 (plugin not yet fully compatible). + settings: { react: { version: '19' } }, rules: { ...pluginReactHooks.configs.recommended.rules, // React scope no longer necessary with new JSX transform. - "react/react-in-jsx-scope": "off", + 'react/react-in-jsx-scope': 'off', }, }, ]; diff --git a/packages/ui/src/components/color-picker.tsx b/packages/ui/src/components/color-picker.tsx index 9f625f4..9ddfddb 100644 --- a/packages/ui/src/components/color-picker.tsx +++ b/packages/ui/src/components/color-picker.tsx @@ -185,9 +185,14 @@ function ColorPicker({ ); useEffect(() => { - setTextColorHsv(getColorAsHsva(textColor || defaultTextColor)); - setBgColorHsv(getColorAsHsva(backgroundColor || defaultBackgroundColor)); - setBorderColorHsv(getColorAsHsva(borderColor || defaultBorderColor)); + const text = getColorAsHsva(textColor || defaultTextColor); + const bg = getColorAsHsva(backgroundColor || defaultBackgroundColor); + const border = getColorAsHsva(borderColor || defaultBorderColor); + queueMicrotask(() => { + setTextColorHsv(text); + setBgColorHsv(bg); + setBorderColorHsv(border); + }); }, [ textColor, backgroundColor, diff --git a/packages/ui/src/components/dynamic-icon.tsx b/packages/ui/src/components/dynamic-icon.tsx index 194d2ea..aa427d1 100644 --- a/packages/ui/src/components/dynamic-icon.tsx +++ b/packages/ui/src/components/dynamic-icon.tsx @@ -34,7 +34,7 @@ async function getLucideIcon(name: string) { } const LucideDynamicIcon = forwardRef( - ({ name, fallback: Fallback, ...props }, ref) => { + function LucideDynamicIcon({ name, fallback: Fallback, ...props }, ref) { const [LucideIcon, setLucideIcon] = useState(); useEffect(() => { @@ -56,8 +56,6 @@ const LucideDynamicIcon = forwardRef( }, ); -export default DynamicIcon; - type DynamicIconProps = React.ComponentProps; export function DynamicIcon( @@ -79,3 +77,5 @@ export function DynamicIcon( /> ); } + +export default DynamicIcon; diff --git a/packages/ui/src/components/icon-picker.tsx b/packages/ui/src/components/icon-picker.tsx index 0e8c5b9..ba24a84 100644 --- a/packages/ui/src/components/icon-picker.tsx +++ b/packages/ui/src/components/icon-picker.tsx @@ -2011,6 +2011,7 @@ const IconPicker = React.forwardRef< const parentRef = React.useRef(null); + // eslint-disable-next-line react-hooks/incompatible-library -- TanStack Virtual useVirtualizer is incompatible with React Compiler memoization const virtualizer = useVirtualizer({ count: virtualItems.length, getScrollElement: () => parentRef.current, diff --git a/packages/ui/src/components/image-file-input.tsx b/packages/ui/src/components/image-file-input.tsx index 1844866..e8d49e0 100644 --- a/packages/ui/src/components/image-file-input.tsx +++ b/packages/ui/src/components/image-file-input.tsx @@ -207,7 +207,7 @@ export const FileUploader = forwardRef< return; } setIsLOF(false); - }, [value, maxFiles]); + }, [value, maxFiles, multiple]); const opts = dropzoneOptions ? dropzoneOptions : { accept, maxFiles, maxSize, multiple }; @@ -338,9 +338,8 @@ export const FileInput = forwardRef diff --git a/packages/ui/src/components/tree.tsx b/packages/ui/src/components/tree.tsx index da53f3d..b8a4481 100644 --- a/packages/ui/src/components/tree.tsx +++ b/packages/ui/src/components/tree.tsx @@ -6,16 +6,16 @@ 'use client'; import * as React from 'react'; -import { ItemInstance } from '@headless-tree/core'; +import { type ItemInstance, type TreeInstance } from '@headless-tree/core'; import { ChevronDownIcon } from '@repo/ui/components/icons'; import { Slot } from 'radix-ui'; import { cn } from '@repo/ui/lib/utils'; -interface TreeContextValue { +interface TreeContextValue { indent: number; currentItem?: ItemInstance; - tree?: any; + tree?: TreeInstance; } const TreeContext = React.createContext({ @@ -24,13 +24,13 @@ const TreeContext = React.createContext({ tree: undefined, }); -function useTreeContext() { +function useTreeContext() { return React.useContext(TreeContext) as TreeContextValue; } interface TreeProps extends React.HTMLAttributes { indent?: number; - tree?: any; + tree?: TreeInstance; } function Tree({ indent = 20, tree, className, ...props }: TreeProps) { @@ -59,13 +59,13 @@ function Tree({ indent = 20, tree, className, ...props }: TreeProps) { ); } -interface TreeItemProps extends React.HTMLAttributes { +interface TreeItemProps extends React.HTMLAttributes { item: ItemInstance; indent?: number; asChild?: boolean; } -function TreeItem({ +function TreeItem({ item, className, asChild, @@ -89,7 +89,7 @@ function TreeItem({ const Comp = asChild ? Slot.Root : 'button'; return ( - + }> ({ ); } -interface TreeItemLabelProps extends React.HTMLAttributes { +interface TreeItemLabelProps extends React.HTMLAttributes { item?: ItemInstance; } -function TreeItemLabel({ +function TreeItemLabel({ item: propItem, children, className, diff --git a/packages/ui/src/hooks/use-container-query.ts b/packages/ui/src/hooks/use-container-query.ts index 8e48f72..f5be80b 100644 --- a/packages/ui/src/hooks/use-container-query.ts +++ b/packages/ui/src/hooks/use-container-query.ts @@ -33,7 +33,7 @@ export function useContainerQuery( const queryList = useMemo(() => { if (!hasMatchContainer) return undefined; return containerElement.matchContainer(query); - }, [containerElement, query]); + }, [containerElement, hasMatchContainer, query]); const [matches, setMatches] = React.useState(queryList?.matches ?? undefined); diff --git a/packages/ui/src/hooks/use-hash.ts b/packages/ui/src/hooks/use-hash.ts index 2d81a62..0f3b073 100644 --- a/packages/ui/src/hooks/use-hash.ts +++ b/packages/ui/src/hooks/use-hash.ts @@ -21,7 +21,7 @@ export const useHash = () => { return () => { window.removeEventListener('hashchange', onHashChange); }; - }, []); + }, [onHashChange]); const _setHash = useCallback( (newHash: string) => { diff --git a/packages/ui/src/hooks/use-local-storage.ts b/packages/ui/src/hooks/use-local-storage.ts index ed1e75d..407f79e 100644 --- a/packages/ui/src/hooks/use-local-storage.ts +++ b/packages/ui/src/hooks/use-local-storage.ts @@ -9,7 +9,7 @@ function dispatchStorageEvent(key: string, newValue: string | null) { window.dispatchEvent(new StorageEvent('storage', { key, newValue })); } -const setLocalStorageItem = (key: string, value: any) => { +const setLocalStorageItem = (key: string, value: unknown) => { const stringifiedValue = JSON.stringify(value); window.localStorage.setItem(key, stringifiedValue); dispatchStorageEvent(key, stringifiedValue); @@ -57,7 +57,7 @@ export function useLocalStorage(key: string, initialValue: T): [T, Dispatch { diff --git a/packages/ui/src/hooks/use-optimistic.ts b/packages/ui/src/hooks/use-optimistic.ts index 8858bfd..85f2131 100644 --- a/packages/ui/src/hooks/use-optimistic.ts +++ b/packages/ui/src/hooks/use-optimistic.ts @@ -16,7 +16,7 @@ export function useOptimistic(passthrough: T, reducer: (state: T, payload: const reducerRef = useRef(reducer); useLayoutEffect(() => { reducerRef.current = reducer; - }, []); + }, [reducer]); const dispatch = useCallback( (payload: P) => { diff --git a/packages/ui/src/hooks/use-throttle.ts b/packages/ui/src/hooks/use-throttle.ts index 6471fdc..a6392df 100644 --- a/packages/ui/src/hooks/use-throttle.ts +++ b/packages/ui/src/hooks/use-throttle.ts @@ -14,7 +14,10 @@ export function useThrottle(value: T, interval = 500) { if (lastUpdated.current && now >= lastUpdated.current + interval) { lastUpdated.current = now; - return setThrottledValue(value); + queueMicrotask(() => { + setThrottledValue(value); + }); + return; } const id = window.setTimeout(() => { diff --git a/packages/ui/src/theme/theme-provider.tsx b/packages/ui/src/theme/theme-provider.tsx index d222a05..f2f8147 100644 --- a/packages/ui/src/theme/theme-provider.tsx +++ b/packages/ui/src/theme/theme-provider.tsx @@ -116,7 +116,10 @@ export function ThemeProvider({ setStoredConfig(storageKey, { theme, scale, mode, color, animations }); }, [theme, scale, mode, color, animations, storageKey]); useEffect(() => { - if (!THEME_BY_VALUE[theme]) setThemeState('new-york'); + if (THEME_BY_VALUE[theme]) return; + queueMicrotask(() => { + setThemeState('new-york'); + }); }, [theme]); useEffect(() => { const root = window.document.documentElement;