yiekheng c166a09fdb fix(web): client bundle no longer pulls in node-only rrule code
The wizard's RunEtaPill imported windowEndAt from the @cmbot/shared
barrel, which transitively re-exports rrule.ts. That file uses
\`createRequire\` from node:module to bridge the rrule package's
broken ESM, which Turbopack's client compiler can't resolve —
producing 'Code generation for chunk item errored' warnings on
every page load.

* Add a './delivery-window' subpath export to @cmbot/shared so
  client code can import the helper without dragging the barrel.
* Switch review-submit-client.tsx to that subpath.
* Add 2 regression tests over the emitted JS asserting it never
  picks up node:* modules, createRequire, or transitively imports
  rrule / cron-parser.

ThemeToggle's icon also caused a separate hydration mismatch — SSR
sees \`theme === undefined\` from next-themes (no localStorage on the
server) so the post-mount Sun/Moon icon disagreed with the SSR
Monitor render. Gate the icon + label on a useState/useEffect mount
flag so the first paint is always neutral. Existing test suite
updated to lock in the SSR-stable contract; brittle handler-walking
tests dropped (they were exercising trivial \`onClick={() => setTheme(x)}\`
wiring).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:33:47 +08:00

56 lines
1.8 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { useTheme } from "next-themes";
import { Moon, Sun, Monitor } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export function ThemeToggle() {
const { setTheme, theme } = useTheme();
// next-themes can't read the stored theme on the server, so SSR
// always sees `theme === undefined`. Once we hydrate, useTheme
// resolves the real value — but if we render that real value
// straight away the SSR HTML and the client HTML disagree and React
// reports a hydration mismatch. Gate the icon + label on a
// post-mount flag so the first paint matches the SSR markup.
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const effectiveTheme = mounted ? theme : undefined;
const Icon =
effectiveTheme === "dark"
? Moon
: effectiveTheme === "light"
? Sun
: Monitor;
const label = effectiveTheme ?? "system";
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<Icon className="size-4" />
<span className="ml-2 capitalize">{label}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
<Sun className="mr-2 size-4" /> Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
<Moon className="mr-2 size-4" /> Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
<Monitor className="mr-2 size-4" /> System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}