chore: setup react-you-might-not-need-an-effect

This commit is contained in:
Julian Sommer
2026-07-27 11:36:16 +10:00
parent 9cec5a50c6
commit 0708ac4816
13 changed files with 128 additions and 93 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -9,6 +9,7 @@ export default defineConfig({
jsPlugins: [
"@tanstack/eslint-plugin-query",
"@tanstack/eslint-plugin-router",
"eslint-plugin-react-you-might-not-need-an-effect",
],
options: {
typeAware: true,
@@ -24,7 +25,6 @@ export default defineConfig({
"@tanstack/query/stable-query-client": "error",
"@tanstack/router/create-route-property-order": "error",
"@tanstack/router/route-param-names": "error",
"react/jsx-handler-names": "off",
"eslint/complexity": "off",
"eslint/func-style": [
"error",
@@ -42,6 +42,18 @@ export default defineConfig({
"eslint/sort-keys": "off",
"import/consistent-type-specifier-style": "off",
"jsx-a11y/prefer-tag-over-role": "off",
"react/jsx-handler-names": "off",
"react-you-might-not-need-an-effect/no-adjust-state-on-prop-change":
"error",
"react-you-might-not-need-an-effect/no-chain-state-updates": "error",
"react-you-might-not-need-an-effect/no-derived-state": "error",
"react-you-might-not-need-an-effect/no-event-handler": "error",
"react-you-might-not-need-an-effect/no-external-store-subscription":
"error",
"react-you-might-not-need-an-effect/no-initialize-state": "error",
"react-you-might-not-need-an-effect/no-pass-live-state-to-parent": "error",
"react-you-might-not-need-an-effect/no-reset-all-state-on-prop-change":
"error",
"typescript/no-confusing-void-expression": "off",
"typescript/no-floating-promises": [
"error",
@@ -57,6 +57,7 @@
"@vitejs/plugin-react": "^6.0.3",
"@vitest/coverage-v8": "^4.1.10",
"babel-plugin-react-compiler": "^1.0.0",
"eslint-plugin-react-you-might-not-need-an-effect": "^1.0.1",
"happy-dom": "^20.10.6",
"oxfmt": "^0.59.0",
"oxlint": "^1.74.0",
+20
View File
@@ -135,6 +135,9 @@ importers:
babel-plugin-react-compiler:
specifier: ^1.0.0
version: 1.0.0
eslint-plugin-react-you-might-not-need-an-effect:
specifier: ^1.0.1
version: 1.0.1(eslint@9.39.4(jiti@2.7.0))
happy-dom:
specifier: ^20.10.6
version: 20.10.6
@@ -1711,6 +1714,12 @@ packages:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
eslint-plugin-react-you-might-not-need-an-effect@1.0.1:
resolution: {integrity: sha512-oOhQTYhor88Xp8RVytq25tvBfiAjU0r9SCDC51Qop+3Wg5BR1xGMAkM+/dV4MZbcMhdaU1L9bkv6LC95JmiTig==}
engines: {node: '>=14.0.0'}
peerDependencies:
eslint: '>=8.40.0'
eslint-scope@8.4.0:
resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1831,6 +1840,10 @@ packages:
resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
engines: {node: '>=18'}
globals@16.5.0:
resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==}
engines: {node: '>=18'}
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
@@ -4061,6 +4074,11 @@ snapshots:
escape-string-regexp@4.0.0: {}
eslint-plugin-react-you-might-not-need-an-effect@1.0.1(eslint@9.39.4(jiti@2.7.0)):
dependencies:
eslint: 9.39.4(jiti@2.7.0)
globals: 16.5.0
eslint-scope@8.4.0:
dependencies:
esrecurse: 4.3.0
@@ -4192,6 +4210,8 @@ snapshots:
globals@14.0.0: {}
globals@16.5.0: {}
graceful-fs@4.2.11: {}
happy-dom@20.10.6:
@@ -6,7 +6,7 @@ import type {
import { zodResolver } from "@hookform/resolvers/zod"
import { useSuspenseQuery } from "@tanstack/react-query"
import { Pencil, Trash2 } from "lucide-react"
import { useEffect, useState } from "react"
import { useState } from "react"
import { Controller, useFieldArray, useForm } from "react-hook-form"
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"
@@ -196,14 +196,17 @@ export function ListenerRulesTab({
</table>
</div>
<RuleDialog
isPending={createMutation.isPending}
mode="add"
onOpenChange={setAddOpen}
onSubmit={handleCreate}
open={addOpen}
targetGroups={targetGroups}
/>
{/* Mounted only while open so every session starts from blank defaults. */}
{addOpen && (
<RuleDialog
isPending={createMutation.isPending}
mode="add"
onOpenChange={setAddOpen}
onSubmit={handleCreate}
open={addOpen}
targetGroups={targetGroups}
/>
)}
<RuleDialog
initialRule={editTarget}
@@ -320,6 +323,25 @@ function toRuleCondInput(c: RuleConditionFormData): RuleCondInput {
}
}
// A new rule starts at the highest priority with a single empty path match.
const DEFAULT_PRIORITY = 1
const defaultConditions: RuleConditionFormData[] = [
{ field: "path-pattern", rawValues: "" },
]
// Projects an existing rule onto the form shape, falling back to the defaults
// for a non-numeric priority or a rule that carries no conditions.
function ruleFormValues(rule: Rule): CreateRuleFormData {
const priority = Number(rule.Priority ?? DEFAULT_PRIORITY)
const conditions: RuleConditionFormData[] =
rule.Conditions?.map((c) => fromSDKCondition(c)) ?? []
return {
priority: Number.isFinite(priority) ? priority : DEFAULT_PRIORITY,
conditions: conditions.length > 0 ? conditions : defaultConditions,
forwardTargetGroupArn: rule.Actions?.[0]?.TargetGroupArn ?? "",
}
}
interface RuleDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
@@ -339,48 +361,22 @@ function RuleDialog({
mode,
initialRule,
}: RuleDialogProps) {
const defaultConds: RuleConditionFormData[] = [
{ field: "path-pattern", rawValues: "" },
]
const form = useForm<CreateRuleFormData>({
resolver: zodResolver(createRuleSchema),
defaultValues: {
priority: 1,
conditions: defaultConds,
priority: DEFAULT_PRIORITY,
conditions: defaultConditions,
forwardTargetGroupArn: "",
},
// Re-seeds the form whenever a different rule is selected. Left undefined
// while closing so the fields do not visibly reset mid-animation.
values: initialRule ? ruleFormValues(initialRule) : undefined,
})
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "conditions",
})
useEffect(() => {
if (mode === "add") {
if (!open) {
form.reset({
priority: 1,
conditions: defaultConds,
forwardTargetGroupArn: "",
})
}
return
}
if (!initialRule) {
return
}
const priorityNum = Number(initialRule.Priority ?? 1)
const conditions: RuleConditionFormData[] =
initialRule.Conditions?.map((c) => fromSDKCondition(c)) ?? defaultConds
form.reset({
priority: Number.isFinite(priorityNum) ? priorityNum : 1,
conditions: conditions.length > 0 ? conditions : defaultConds,
forwardTargetGroupArn: initialRule.Actions?.[0]?.TargetGroupArn ?? "",
})
// form is intentionally excluded so the reset only fires on identity change of the source.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialRule, open, mode])
const handleConfirm = form.handleSubmit(onSubmit)
return (
@@ -2,7 +2,7 @@ import type { Listener } from "@aws-sdk/client-elastic-load-balancing-v2"
import { zodResolver } from "@hookform/resolvers/zod"
import { useSuspenseQuery } from "@tanstack/react-query"
import { Pencil, Trash2 } from "lucide-react"
import { Fragment, useEffect, useState } from "react"
import { Fragment, useState } from "react"
import { useForm } from "react-hook-form"
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"
@@ -56,6 +56,26 @@ function listenerDefaults(lbType: LbType): CreateListenerFormData {
}
}
// Projects an existing listener onto the form shape. TLS fields are dropped for
// plaintext protocols so a downgrade does not carry a stale certificate.
function listenerFormValues(
listener: Listener,
protocols: readonly ListenerProtocolValue[],
): CreateListenerFormData {
const protocol: ListenerProtocolValue =
protocols.find((p) => p === listener.Protocol) ?? protocols[0] ?? "HTTP"
const secure = isSecureProtocol(protocol)
return {
protocol,
port: listener.Port ?? 80,
defaultTargetGroupArn: listener.DefaultActions?.[0]?.TargetGroupArn ?? "",
certificateArn: secure
? listener.Certificates?.[0]?.CertificateArn
: undefined,
sslPolicy: protocol === "HTTPS" ? listener.SslPolicy : undefined,
}
}
function formatTls(listener: Listener): string {
if (listener.Protocol !== "HTTPS") {
return "—"
@@ -276,17 +296,20 @@ export function ListenersTab({
</div>
)}
<AddListenerDialog
certificates={certificates}
isPending={createMutation.isPending}
lbType={lbType}
onOpenChange={setAddOpen}
onSubmit={handleCreate}
open={addOpen}
protocols={protocols}
sslPolicies={sslPolicies}
targetGroups={vpcTgs}
/>
{/* Mounted only while open so every session starts from blank defaults. */}
{addOpen && (
<AddListenerDialog
certificates={certificates}
isPending={createMutation.isPending}
lbType={lbType}
onOpenChange={setAddOpen}
onSubmit={handleCreate}
open={addOpen}
protocols={protocols}
sslPolicies={sslPolicies}
targetGroups={vpcTgs}
/>
)}
<EditListenerDialog
certificates={certificates}
@@ -343,12 +366,6 @@ function AddListenerDialog({
defaultValues: listenerDefaults(lbType),
})
useEffect(() => {
if (!open) {
form.reset(listenerDefaults(lbType))
}
}, [open, form, lbType])
const handleConfirm = form.handleSubmit(onSubmit)
return (
@@ -421,26 +438,11 @@ function EditListenerDialog({
port: 80,
defaultTargetGroupArn: "",
},
// Re-seeds the form whenever a different listener is selected. Left
// undefined while closing so the fields do not visibly reset mid-animation.
values: listener ? listenerFormValues(listener, protocols) : undefined,
})
useEffect(() => {
if (!listener) {
return
}
const protocol: ListenerProtocolValue =
protocols.find((p) => p === listener.Protocol) ?? protocols[0] ?? "HTTP"
const secure = isSecureProtocol(protocol)
form.reset({
protocol,
port: listener.Port ?? 80,
defaultTargetGroupArn: listener.DefaultActions?.[0]?.TargetGroupArn ?? "",
certificateArn: secure
? listener.Certificates?.[0]?.CertificateArn
: undefined,
sslPolicy: protocol === "HTTPS" ? listener.SslPolicy : undefined,
})
}, [listener, form, protocols])
const handleConfirm = form.handleSubmit(onSubmit)
return (
@@ -38,6 +38,9 @@ export function ThemeProvider({
getStoredTheme(storageKey, defaultTheme),
)
// Syncing the theme class onto <html> is a genuine external-system write: it
// has to run on mount for the stored theme, not just on user selection.
/* oxlint-disable react-you-might-not-need-an-effect/no-event-handler */
useEffect(() => {
const root = window.document.documentElement
@@ -55,6 +58,7 @@ export function ThemeProvider({
root.classList.add(theme)
}, [theme])
/* oxlint-enable react-you-might-not-need-an-effect/no-event-handler */
const value = useMemo(
() => ({