refactor(webapp): derive controlled UI state during render (#4726)
## Summary Derives controlled tab, tag, and checkbox values directly during render instead of copying them through effects. Modal drafts now reset from their open event, and the route-backed alert dialog renders open immediately without a mount-time state update.
This commit is contained in:
@@ -86,7 +86,7 @@ export const CheckboxWithLabel = React.forwardRef<HTMLInputElement, CheckboxProp
|
||||
ref
|
||||
) => {
|
||||
const [isChecked, setIsChecked] = useState<boolean>(defaultChecked ?? false);
|
||||
const [isDisabled, setIsDisabled] = useState<boolean>(disabled ?? false);
|
||||
const isDisabled = disabled ?? false;
|
||||
const onChangeRef = React.useRef(onChange);
|
||||
const generatedId = React.useId();
|
||||
const inputId = id ?? generatedId;
|
||||
@@ -102,10 +102,6 @@ export const CheckboxWithLabel = React.forwardRef<HTMLInputElement, CheckboxProp
|
||||
const isDisabledClassName = variants[variant].isDisabled;
|
||||
const inputPositionClasses = variants[variant].inputPosition;
|
||||
|
||||
useEffect(() => {
|
||||
setIsDisabled(disabled ?? false);
|
||||
}, [disabled]);
|
||||
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
}, [onChange]);
|
||||
|
||||
@@ -20,18 +20,13 @@ const ClientTabs = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Root>
|
||||
>(({ onValueChange, value: valueProp, defaultValue, ...props }, ref) => {
|
||||
const [value, setValue] = React.useState<string | undefined>(valueProp ?? defaultValue);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (valueProp !== undefined) {
|
||||
setValue(valueProp);
|
||||
}
|
||||
}, [valueProp]);
|
||||
const [internalValue, setInternalValue] = React.useState<string | undefined>(defaultValue);
|
||||
const value = valueProp ?? internalValue;
|
||||
|
||||
const handleValueChange = React.useCallback(
|
||||
(nextValue: string) => {
|
||||
if (valueProp === undefined) {
|
||||
setValue(nextValue);
|
||||
setInternalValue(nextValue);
|
||||
}
|
||||
onValueChange?.(nextValue);
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useState, useEffect, type KeyboardEvent } from "react";
|
||||
import { useCallback, useState, type KeyboardEvent } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { RunTag } from "./RunTag";
|
||||
@@ -26,39 +26,30 @@ export function RunTagInput({
|
||||
maxTagLength = 128,
|
||||
onTagsChange,
|
||||
}: TagInputProps) {
|
||||
// Use controlled tags if provided, otherwise use default
|
||||
const initialTags = controlledTags ?? defaultTags;
|
||||
|
||||
const [tags, setTags] = useState<string[]>(initialTags);
|
||||
const [internalTags, setInternalTags] = useState<string[]>(defaultTags);
|
||||
const tags = controlledTags ?? internalTags;
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
|
||||
// Sync internal state with external tag changes
|
||||
useEffect(() => {
|
||||
if (controlledTags !== undefined) {
|
||||
setTags(controlledTags);
|
||||
}
|
||||
}, [controlledTags]);
|
||||
|
||||
const addTag = useCallback(
|
||||
(tagText: string) => {
|
||||
const trimmedTag = tagText.trim();
|
||||
if (trimmedTag && !tags.includes(trimmedTag) && tags.length < maxTags) {
|
||||
const newTags = [...tags, trimmedTag];
|
||||
setTags(newTags);
|
||||
if (controlledTags === undefined) setInternalTags(newTags);
|
||||
onTagsChange?.(newTags);
|
||||
}
|
||||
setInputValue("");
|
||||
},
|
||||
[tags, onTagsChange, maxTags]
|
||||
[tags, controlledTags, onTagsChange, maxTags]
|
||||
);
|
||||
|
||||
const removeTag = useCallback(
|
||||
(tagToRemove: string) => {
|
||||
const newTags = tags.filter((tag) => tag !== tagToRemove);
|
||||
setTags(newTags);
|
||||
if (controlledTags === undefined) setInternalTags(newTags);
|
||||
onTagsChange?.(newTags);
|
||||
},
|
||||
[tags, onTagsChange]
|
||||
[tags, controlledTags, onTagsChange]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
|
||||
@@ -67,12 +67,16 @@ export function PurchaseSchedulesModal({
|
||||
const isLoading = fetcher.state !== "idle";
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
// Reset the bundle stepper to the user's current extra-schedules count on
|
||||
// each open. Earlier this only re-synced when `extraSchedules`/`stepSize`
|
||||
// props changed, so if the user opened the modal, typed a value, cancelled,
|
||||
// and reopened without purchasing, the stale draft persisted.
|
||||
// Reset the bundle stepper to the user's current extra-schedules count on each open.
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (nextOpen) setBundles(Math.round(extraSchedules / stepSize));
|
||||
setOpen(nextOpen);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setBundles(Math.round(extraSchedules / stepSize));
|
||||
if (!open) return;
|
||||
// oxlint-disable-next-line react/react-compiler -- Keep the open draft aligned with authoritative billing values.
|
||||
setBundles(Math.round(extraSchedules / stepSize));
|
||||
}, [open, extraSchedules, stepSize]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -113,7 +117,7 @@ export function PurchaseSchedulesModal({
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
{triggerButton ?? (
|
||||
<Button variant="primary/small" onClick={() => setOpen(true)}>
|
||||
|
||||
+1
-6
@@ -239,7 +239,6 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { slack, option, emailAlertsEnabled } = useTypedLoaderData<typeof loader>();
|
||||
const lastSubmission = useActionData();
|
||||
const navigation = useNavigation();
|
||||
@@ -274,10 +273,6 @@ export default function Page() {
|
||||
shouldRevalidate: "onSubmit",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (navigation.state !== "idle") return;
|
||||
if (lastSubmission !== undefined) return;
|
||||
@@ -287,7 +282,7 @@ export default function Page() {
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
open
|
||||
onOpenChange={(o) => {
|
||||
if (!o) {
|
||||
navigate(v3ProjectAlertsPath(organization, project, environment));
|
||||
|
||||
Reference in New Issue
Block a user