🔧 chore: upgrade to Next.js 16 and update ESLint config#104
🔧 chore: upgrade to Next.js 16 and update ESLint config#104cojocaru-david wants to merge 1 commit into
Conversation
cojocaru-david
commented
Mar 2, 2026
- Upgrade next to 16.1.6 and align eslint-config-next
- Update React to 19.2.4 and related type packages
- Bump various dependencies (drizzle-orm, gsap, lucide-react, pg, zod, etc.)
- Refactor eslint.config.mjs to use direct next/core-web-vitals and next/typescript imports
- Remove eslint.ignoreDuringBuilds from next.config.ts
- Upgrade next to 16.1.6 and align eslint-config-next - Update React to 19.2.4 and related type packages - Bump various dependencies (drizzle-orm, gsap, lucide-react, pg, zod, etc.) - Refactor eslint.config.mjs to use direct next/core-web-vitals and next/typescript imports - Remove eslint.ignoreDuringBuilds from next.config.ts
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Summary by CodeRabbit
WalkthroughThis PR encompasses a comprehensive project modernization cycle: ESLint configuration simplification, Next.js version upgrade (15.5.3 → 16.1.6) with associated dependency updates, OG image generation component rewrite, UI component refinements, theme context initialization simplification, and TypeScript JSX runtime configuration update. Changes
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
Tip Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs). Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/app/api/og/route.tsx (1)
464-467:⚠️ Potential issue | 🟠 MajorETag validation occurs after image generation, negating caching benefit.
The
if-none-matchcheck at lines 464-467 happens after theImageResponseis already created at line 142. This means the expensive image generation runs on every request, even when a 304 response could be returned immediately.Move the ETag check before image generation to avoid unnecessary work.
🐛 Proposed fix to check ETag before generation
const cacheKey = `og-${title}-${description}-${language}-${selectedTheme}-${authorName}`; + const etag = `"${Buffer.from(cacheKey).toString("base64")}"`; + + const ifNoneMatch = request.headers.get("if-none-match"); + if (ifNoneMatch === etag) { + return new Response(null, { status: 304 }); + } const response = new ImageResponse( // ... image generation code ... ); response.headers.set( "Cache-Control", "public, s-maxage=86400, stale-while-revalidate=43200", ); response.headers.set("CDN-Cache-Control", "public, s-maxage=86400"); response.headers.set("Vercel-CDN-Cache-Control", "public, s-maxage=86400"); - - const etag = `"${Buffer.from(cacheKey).toString("base64")}"`; response.headers.set("ETag", etag); - - const ifNoneMatch = request.headers.get("if-none-match"); - if (ifNoneMatch === etag) { - return new Response(null, { status: 304 }); - } return response;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/api/og/route.tsx` around lines 464 - 467, The ETag check is performed after creating the expensive ImageResponse (variable ImageResponse) so caching is ineffective; move the if-none-match/ifNoneMatch check to run before you construct ImageResponse: compute the same etag value using only request inputs/params (the same inputs used to build the image) without instantiating ImageResponse, check if request.headers.get("if-none-match") equals etag and return new Response(null, { status: 304 }) immediately if it matches, otherwise proceed to generate the ImageResponse; update/keep the etag variable and the ifNoneMatch comparison logic (variables etag and ifNoneMatch) in the request handler so the early-return happens prior to calling new ImageResponse.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@package.json`:
- Line 83: Upgrade to "shiki": "^4.0.1" requires Node.js ≥ 20; verify and
enforce this by checking your runtime environments (local dev, CI/CD, and
production), update package.json engines to "node": ">=20" if appropriate, and
ensure any Docker images/CI runners are using Node 20+; also confirm
src/lib/shiki.ts usages (createHighlighter, codeToHtml, transformers,
loadLanguage, loadTheme) are compatible with Shiki 4.x in your tests and update
CI matrix to run against Node 20 to catch incompatibilities early.
In `@src/components/ui/combobox.tsx`:
- Around line 145-165: The effect that syncs the combobox trigger width is
adding both a ResizeObserver and a window "resize" listener, which is redundant;
remove the window.addEventListener("resize", updateWidth) and its corresponding
window.removeEventListener cleanup from the useEffect that depends on open, and
rely solely on the ResizeObserver (created for triggerRef.current) to call
updateWidth and the existing cleanup that calls resizeObserver.disconnect();
keep references to updateWidth, triggerRef, setTriggerWidth, ResizeObserver and
the open dependency unchanged.
In `@src/components/ui/TextType.tsx`:
- Around line 202-218: The wrapper <span> currently receives containerRef and
fixed classNames while intrinsic elements get the ref and componentProps
directly, causing startOnVisible to observe the wrapper and layout mismatches;
update the non-string branch so instead of wrapping in a span you inject
containerRef and any needed className/whitespace props into componentProps (so
the custom component receives ref via componentProps and the same styling),
ensuring custom components forward refs (e.g., via forwardRef) or support React
19 ref-as-prop; keep using createElement(Component, componentProps, content)
after adding the ref and className to componentProps.
In `@src/contexts/ThemeContext.tsx`:
- Around line 41-44: The resolvedTheme state initialization causes SSR/CSR
mismatch; change the resolvedTheme useState in ThemeContext.tsx to a stable
server-safe default (e.g., "light") instead of calling
resolveTheme(getInitialTheme()), and add a client-only effect (useEffect) that
runs on mount to compute the real value via resolveTheme(getInitialTheme()) and
call setResolvedTheme; reference the existing variables/functions theme,
resolvedTheme, setResolvedTheme, getInitialTheme(), and resolveTheme to locate
and update the code.
- Line 96: The ThemeContext currently hardcodes isLoading: false which can cause
flashes; restore a loading state in the context and ThemeProvider so consumers
can wait for initialization—add an isLoading boolean to the context default and
state in ThemeProvider (e.g., ThemeContext, ThemeProvider, initializeTheme or
the useEffect that reads persisted theme), set isLoading=true until the theme is
applied, then set isLoading=false after initialization completes; alternatively
ensure server-rendered/default CSS/theme is neutral if you intentionally keep
isLoading removed.
---
Outside diff comments:
In `@src/app/api/og/route.tsx`:
- Around line 464-467: The ETag check is performed after creating the expensive
ImageResponse (variable ImageResponse) so caching is ineffective; move the
if-none-match/ifNoneMatch check to run before you construct ImageResponse:
compute the same etag value using only request inputs/params (the same inputs
used to build the image) without instantiating ImageResponse, check if
request.headers.get("if-none-match") equals etag and return new Response(null, {
status: 304 }) immediately if it matches, otherwise proceed to generate the
ImageResponse; update/keep the etag variable and the ifNoneMatch comparison
logic (variables etag and ifNoneMatch) in the request handler so the
early-return happens prior to calling new ImageResponse.
ℹ️ Review info
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Cache: Disabled due to Reviews > Disable Cache setting
⛔ Files ignored due to path filters (1)
package-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (9)
eslint.config.mjsnext.config.tspackage.jsonsrc/app/api/og/route.tsxsrc/components/ui/TextType.tsxsrc/components/ui/avatar.tsxsrc/components/ui/combobox.tsxsrc/contexts/ThemeContext.tsxtsconfig.json
💤 Files with no reviewable changes (2)
- next.config.ts
- src/components/ui/avatar.tsx
| "eslint-config-next": "16.1.6", | ||
| "prettier": "^3.8.1", | ||
| "esbuild": "^0.27.3", | ||
| "shiki": "^4.0.1", |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Check Shiki usage patterns in the codebase
echo "=== Shiki imports and usage ==="
rg -n 'from ["\']shiki' --type=ts --type=tsx -g '!node_modules'
echo ""
echo "=== Shiki API calls ==="
rg -n -A3 'getHighlighter|createHighlighter|codeToHtml' --type=ts --type=tsx -g '!node_modules'Repository: cojocaru-david/snippetslibrary.com
Length of output: 195
🏁 Script executed:
#!/bin/bash
# Check Shiki usage patterns in the codebase
echo "=== Shiki imports and usage ==="
rg -n 'from.*shiki' --type=ts --type=tsx -g '!node_modules'
echo ""
echo "=== Shiki API calls ==="
rg -n 'getHighlighter|createHighlighter|codeToHtml' --type=ts --type=tsx -g '!node_modules'
echo ""
echo "=== Checking src/lib/shiki.ts specifically ==="
ls -la src/lib/shiki.ts 2>/dev/null || echo "File not found in src/lib/"
echo ""
echo "=== Searching for shiki files ==="
fd 'shiki' --type=f -g '!node_modules'Repository: cojocaru-david/snippetslibrary.com
Length of output: 458
🏁 Script executed:
#!/bin/bash
# Check Shiki usage patterns in the codebase
echo "=== Shiki imports and usage ==="
rg -n 'from.*shiki|import.*shiki' -g '!node_modules'
echo ""
echo "=== Reading src/lib/shiki.ts ==="
cat -n src/lib/shiki.ts
echo ""
echo "=== Shiki API calls (getHighlighter, createHighlighter, codeToHtml) ==="
rg -n 'getHighlighter|createHighlighter|codeToHtml' -g '!node_modules'Repository: cojocaru-david/snippetslibrary.com
Length of output: 31632
🌐 Web query:
Shiki 4.x breaking changes migration guide
💡 Result:
Shiki 4.x breaking changes (v3 → v4) & migration checklist
- Upgrade Node.js first
- Shiki v4 requires Node.js ≥ 20 (Node 18 is not supported). Update your runtime/CI before bumping Shiki. [1]
- Fix removed typo’d API names (now fully removed in v4)
- Type renamed (typo removed):
CreatedBundledHighlighterOptions→CreateBundledHighlighterOptions[1]
- Function renamed (typo removed):
createdBundledHighlighter→createBundledHighlighter[1]
- If you use
@shikijs/vitepress-twoslash: option renamed
TwoslashFloatingVueno longer accepts singulartheme; usethemesinstead. [1]
- If you style Twoslash “query persisted” UI: CSS class typo removed
twoslash-query-presisted(misspelled) is removed; usetwoslash-query-persisted. [1]
Suggested upgrade path
- Upgrade to the latest v3 first, then bump to v4 and apply the changes above (Shiki recommends step-by-step migration by major version). [2]
Sources: Shiki v4 migration notes [1], Shiki migration guide [2].
🌐 Web query:
Shiki 4.0 createHighlighter themes langs parameters API
💡 Result:
createHighlighter() (Shiki v4)
import { createHighlighter } from 'shiki'
const highlighter = await createHighlighter({
themes: ['nord', 'github-dark'], // array (no singular `theme`)
langs: ['js', 'ts'], // array
})themes: an array of theme names (bundled) and/or custom theme objects. [1][2]langs: an array of language IDs to preload (must be loaded before use, or load later viahighlighter.loadLanguage(...)). [1]- Note (breaking change):
createHighlighter({ theme: ... })is dropped—usethemes: [...]. [3]
Fine‑grained / core API (when you want explicit imports + engine control)
import { createHighlighterCore } from 'shiki/core'
import { createOnigurumaEngine } from 'shiki/engine/oniguruma'
const highlighter = await createHighlighterCore({
themes: [import('@shikijs/themes/nord')],
langs: [import('@shikijs/langs/javascript')],
engine: createOnigurumaEngine(() => import('shiki/wasm')),
})themes: theme modules/objects you import yourself. [4]langs: language modules/objects you import yourself. [4]engine: required for the sync core API, and commonly provided with core setups (Oniguruma/WASM or JS RegExp engine). [4][5]
Sources:
[1] Shiki “Installation & Usage” (createHighlighter with themes/langs, loadTheme/loadLanguage) (shiki.style)
[2] Shiki “Load Custom Themes” (passing a Theme object via themes) (shiki.matsu.io)
[3] Shiki “Migration” (theme option dropped; use themes array) (shiki.matsu.io)
[4] Shiki @shikijs/markdown-it docs (core highlighter with themes/langs imports + engine) (shiki.matsu.io)
[5] Shiki “Synchronous Usage” (core sync requires plain-object themes/langs + explicit engine) (shiki.style)
Citations:
- 1: https://shiki.style/guide/install/
- 2: https://shiki.matsu.io/guide/load-theme?utm_source=openai
- 3: https://shiki.matsu.io/guide/migrate?utm_source=openai
- 4: https://shiki.matsu.io/packages/markdown-it?utm_source=openai
- 5: https://shiki.style/guide/sync-usage?utm_source=openai
🌐 Web query:
Shiki 4.0 codeToHtml transformers API
💡 Result:
In Shiki v4, codeToHtml() takes a transformers option: an array of ShikiTransformer objects that let you modify output by hooking into different stages of tokenization + HAST (HTML AST) generation. [1]
Basic usage
import { codeToHtml } from 'shiki'
const html = await codeToHtml(code, {
lang: 'ts',
theme: 'vitesse-light',
transformers: [
{
name: 'my-transformer',
code(node) {
this.addClassToHast(node, 'language-ts')
},
line(node, line) {
node.properties['data-line'] = line
},
span(node, line, col) {
node.properties['data-token'] = `token:${line}:${col}`
},
},
],
})[1]
Transformer hooks (API surface)
A ShikiTransformer can implement any of these hooks: preprocess, tokens, span, line, code, pre, root, postprocess. [1]
Ordering
You can force ordering with enforce: 'pre' | 'post' (otherwise it runs in normal order). [1]
Meta access (in supported integrations)
Inside a transformer you can read e.g. this.options.meta.__raw. [1]
v4 note
Shiki v4.0.0 mainly removes deprecated APIs and requires Node >= 20; the Transformers approach remains the way to customize HTML output. [2]
Sources: Transformers guide [1], Shiki v4.0.0 announcement [2].
Verify Node.js ≥ 20 requirement for Shiki 4.x upgrade.
Shiki 4.x requires Node.js ≥ 20 (previously ≥ 18). Verify that your deployment environment, CI/CD pipeline, and development setup support Node.js 20 or later. The API usage in src/lib/shiki.ts (createHighlighter, codeToHtml, transformers, loadLanguage, loadTheme, etc.) is compatible with Shiki 4.x.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@package.json` at line 83, Upgrade to "shiki": "^4.0.1" requires Node.js ≥ 20;
verify and enforce this by checking your runtime environments (local dev, CI/CD,
and production), update package.json engines to "node": ">=20" if appropriate,
and ensure any Docker images/CI runners are using Node 20+; also confirm
src/lib/shiki.ts usages (createHighlighter, codeToHtml, transformers,
loadLanguage, loadTheme) are compatible with Shiki 4.x in your tests and update
CI matrix to run against Node 20 to catch incompatibilities early.
| React.useEffect(() => { | ||
| if (!open) return; | ||
|
|
||
| const updateWidth = () => { | ||
| setTriggerWidth(triggerRef.current?.offsetWidth); | ||
| }; | ||
|
|
||
| updateWidth(); | ||
|
|
||
| const triggerElement = triggerRef.current; | ||
| if (!triggerElement) return; | ||
|
|
||
| const resizeObserver = new ResizeObserver(updateWidth); | ||
| resizeObserver.observe(triggerElement); | ||
| window.addEventListener("resize", updateWidth); | ||
|
|
||
| return () => { | ||
| resizeObserver.disconnect(); | ||
| window.removeEventListener("resize", updateWidth); | ||
| }; | ||
| }, [open]); |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
Redundant window resize listener.
The ResizeObserver already detects size changes caused by window resizing, making the separate window.addEventListener("resize", ...) unnecessary. Removing it simplifies the code and avoids duplicate width calculations.
♻️ Proposed simplification
React.useEffect(() => {
if (!open) return;
const updateWidth = () => {
setTriggerWidth(triggerRef.current?.offsetWidth);
};
updateWidth();
const triggerElement = triggerRef.current;
if (!triggerElement) return;
const resizeObserver = new ResizeObserver(updateWidth);
resizeObserver.observe(triggerElement);
- window.addEventListener("resize", updateWidth);
return () => {
resizeObserver.disconnect();
- window.removeEventListener("resize", updateWidth);
};
}, [open]);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| React.useEffect(() => { | |
| if (!open) return; | |
| const updateWidth = () => { | |
| setTriggerWidth(triggerRef.current?.offsetWidth); | |
| }; | |
| updateWidth(); | |
| const triggerElement = triggerRef.current; | |
| if (!triggerElement) return; | |
| const resizeObserver = new ResizeObserver(updateWidth); | |
| resizeObserver.observe(triggerElement); | |
| window.addEventListener("resize", updateWidth); | |
| return () => { | |
| resizeObserver.disconnect(); | |
| window.removeEventListener("resize", updateWidth); | |
| }; | |
| }, [open]); | |
| React.useEffect(() => { | |
| if (!open) return; | |
| const updateWidth = () => { | |
| setTriggerWidth(triggerRef.current?.offsetWidth); | |
| }; | |
| updateWidth(); | |
| const triggerElement = triggerRef.current; | |
| if (!triggerElement) return; | |
| const resizeObserver = new ResizeObserver(updateWidth); | |
| resizeObserver.observe(triggerElement); | |
| return () => { | |
| resizeObserver.disconnect(); | |
| }; | |
| }, [open]); |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/components/ui/combobox.tsx` around lines 145 - 165, The effect that syncs
the combobox trigger width is adding both a ResizeObserver and a window "resize"
listener, which is redundant; remove the window.addEventListener("resize",
updateWidth) and its corresponding window.removeEventListener cleanup from the
useEffect that depends on open, and rely solely on the ResizeObserver (created
for triggerRef.current) to call updateWidth and the existing cleanup that calls
resizeObserver.disconnect(); keep references to updateWidth, triggerRef,
setTriggerWidth, ResizeObserver and the open dependency unchanged.
| if (typeof Component === "string") { | ||
| const IntrinsicComponent = Component as React.ElementType; | ||
| return ( | ||
| <IntrinsicComponent | ||
| {...componentProps} | ||
| ref={containerRef as React.Ref<HTMLElement>} | ||
| > | ||
| {cursorCharacter} | ||
| </span> | ||
| ), | ||
| {content} | ||
| </IntrinsicComponent> | ||
| ); | ||
| } | ||
|
|
||
| return ( | ||
| <span ref={containerRef} className="inline-block whitespace-pre-wrap"> | ||
| {createElement(Component, componentProps, content)} | ||
| </span> | ||
| ); |
There was a problem hiding this comment.
Inconsistent ref handling and styling for custom components.
When Component is a custom React component (non-string), the containerRef is attached to a wrapper <span> instead of the component itself, while intrinsic elements receive the ref directly. This causes:
startOnVisibleobserves the wrapper span, not the actual component- The wrapper span has different styling (
"inline-block whitespace-pre-wrap") thancomponentProps, leading to inconsistent layout
Consider passing ref through componentProps for custom components or documenting this behavioral difference.
💡 Suggested approach for consistent ref handling
- if (typeof Component === "string") {
- const IntrinsicComponent = Component as React.ElementType;
- return (
- <IntrinsicComponent
- {...componentProps}
- ref={containerRef as React.Ref<HTMLElement>}
- >
- {content}
- </IntrinsicComponent>
- );
- }
-
- return (
- <span ref={containerRef} className="inline-block whitespace-pre-wrap">
- {createElement(Component, componentProps, content)}
- </span>
- );
+ return createElement(
+ Component,
+ { ...componentProps, ref: containerRef },
+ content
+ );Note: This simplified approach requires custom components to forward refs properly via forwardRef or React 19's ref-as-prop pattern.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if (typeof Component === "string") { | |
| const IntrinsicComponent = Component as React.ElementType; | |
| return ( | |
| <IntrinsicComponent | |
| {...componentProps} | |
| ref={containerRef as React.Ref<HTMLElement>} | |
| > | |
| {cursorCharacter} | |
| </span> | |
| ), | |
| {content} | |
| </IntrinsicComponent> | |
| ); | |
| } | |
| return ( | |
| <span ref={containerRef} className="inline-block whitespace-pre-wrap"> | |
| {createElement(Component, componentProps, content)} | |
| </span> | |
| ); | |
| return createElement( | |
| Component, | |
| { ...componentProps, ref: containerRef }, | |
| content | |
| ); |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/components/ui/TextType.tsx` around lines 202 - 218, The wrapper <span>
currently receives containerRef and fixed classNames while intrinsic elements
get the ref and componentProps directly, causing startOnVisible to observe the
wrapper and layout mismatches; update the non-string branch so instead of
wrapping in a span you inject containerRef and any needed className/whitespace
props into componentProps (so the custom component receives ref via
componentProps and the same styling), ensuring custom components forward refs
(e.g., via forwardRef) or support React 19 ref-as-prop; keep using
createElement(Component, componentProps, content) after adding the ref and
className to componentProps.
| const [theme, setTheme] = useState<UITheme>(() => getInitialTheme()); | ||
| const [resolvedTheme, setResolvedTheme] = useState<"dark" | "light">(() => | ||
| resolveTheme(getInitialTheme()), | ||
| ); |
There was a problem hiding this comment.
Potential hydration mismatch with theme initialization.
During SSR, resolveTheme(getInitialTheme()) returns "light" (since window is undefined), but on the client, it may resolve to "dark" based on localStorage or system preference. This can cause a hydration mismatch and a visible theme flash.
Consider keeping the initial resolvedTheme consistent between server and client (e.g., always start with a known value like "light") and only apply the actual resolved theme in a client-side effect.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/contexts/ThemeContext.tsx` around lines 41 - 44, The resolvedTheme state
initialization causes SSR/CSR mismatch; change the resolvedTheme useState in
ThemeContext.tsx to a stable server-safe default (e.g., "light") instead of
calling resolveTheme(getInitialTheme()), and add a client-only effect
(useEffect) that runs on mount to compute the real value via
resolveTheme(getInitialTheme()) and call setResolvedTheme; reference the
existing variables/functions theme, resolvedTheme, setResolvedTheme,
getInitialTheme(), and resolveTheme to locate and update the code.
| setTheme: handleSetTheme, | ||
| resolvedTheme, | ||
| isLoading, | ||
| isLoading: false, |
There was a problem hiding this comment.
Hardcoded isLoading: false may cause theme flash.
The removal of the loading state means consumers can no longer conditionally render a loading indicator while the theme initializes. Combined with the hydration mismatch above, users may see a brief flash of the wrong theme on page load.
If this is intentional to simplify the API, ensure the server-rendered HTML uses a neutral or expected default theme, or consider using CSS to hide content until the theme is applied.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/contexts/ThemeContext.tsx` at line 96, The ThemeContext currently
hardcodes isLoading: false which can cause flashes; restore a loading state in
the context and ThemeProvider so consumers can wait for initialization—add an
isLoading boolean to the context default and state in ThemeProvider (e.g.,
ThemeContext, ThemeProvider, initializeTheme or the useEffect that reads
persisted theme), set isLoading=true until the theme is applied, then set
isLoading=false after initialization completes; alternatively ensure
server-rendered/default CSS/theme is neutral if you intentionally keep isLoading
removed.