diff --git a/AGENTS.md b/AGENTS.md index 3dfcf13a3..71ba99c07 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,7 @@ - state management is done via plain `useState` and React Context API - avoid using `useEffect` - all texts should be provided translations via the i18next library's `useTranslations` hook's `t` function -- instead of `&&` operator for conditional rendering, use the ternary operator +- instead of `&&` operator for conditional rendering, use the ternary operator; enforced by the `no-and-conditional-rendering` Biome plugin - for localized user-readable time strings use ``, `` or `useFormatDistanceToNow`. If needed use `useDateTimeFormat` directly. NEVER use e.g. `toLocaleString` directly as it does not include users' language selection. ## Forms diff --git a/app/components/BuildCard.tsx b/app/components/BuildCard.tsx index d0bc6fec0..b1c673cbd 100644 --- a/app/components/BuildCard.tsx +++ b/app/components/BuildCard.tsx @@ -103,7 +103,7 @@ export function BuildCard({ >
- {modes && modes.length > 0 && ( + {modes && modes.length > 0 ? (
{modes.map((mode) => ( ))}
- )} + ) : null}

{title}

@@ -159,11 +159,11 @@ export function BuildCard({ {weapons.map((weapon) => ( ))} - {weapons.length === 1 && ( + {weapons.length === 1 ? (
{t(`weapons:MAIN_${weapons[0].weaponSplId}` as any)}
- )} + ) : null}
) : null} - {canEdit && ( + {canEdit ? ( <> - )} + ) : null}
); diff --git a/app/components/Chart.tsx b/app/components/Chart.tsx index fd049159f..634a83977 100644 --- a/app/components/Chart.tsx +++ b/app/components/Chart.tsx @@ -296,7 +296,7 @@ export default function Chart({ }, }} /> - {tooltipData && ( + {tooltipData ? (
))}
- )} + ) : null} ); } diff --git a/app/components/Label.tsx b/app/components/Label.tsx index 207835af0..bba6441fa 100644 --- a/app/components/Label.tsx +++ b/app/components/Label.tsx @@ -30,7 +30,7 @@ export function Label({ return (
{valueLimits ? (
- {Boolean(title) && {title}} - {Boolean(handleRemoval || allowBulkEdit) && ( + {title ? {title} : null} + {handleRemoval || allowBulkEdit ? (
- {handleRemoval && ( + {handleRemoval ? ( {t("actions.remove")} - )} - {allowBulkEdit && ( + ) : null} + {allowBulkEdit ? ( {t("actions.clear")} - )} + ) : null}
- )} + ) : null}
- {allowBulkEdit && ( + {allowBulkEdit ? ( - )} + ) : null} {info} ); })} - {!isPresentational && - allowBulkEdit && - (mapPool.hasStage(stageId) ? ( + {!isPresentational && allowBulkEdit ? ( + mapPool.hasStage(stageId) ? ( - ))} + ) + ) : null}
@@ -358,7 +358,7 @@ function MapPoolTemplateSelect({ ))} - {recentEvents && recentEvents.length > 0 && ( + {recentEvents && recentEvents.length > 0 ? ( {recentEvents.map((event) => ( ))} - )} + ) : null} ); diff --git a/app/components/Section.tsx b/app/components/Section.tsx index eb121e319..2d08c3146 100644 --- a/app/components/Section.tsx +++ b/app/components/Section.tsx @@ -11,7 +11,7 @@ export function Section({ }) { return (
- {title &&

{title}

} + {title ?

{title}

: null}
{children}
); diff --git a/app/components/elements/Button.tsx b/app/components/elements/Button.tsx index b38b20c8f..abd74f142 100644 --- a/app/components/elements/Button.tsx +++ b/app/components/elements/Button.tsx @@ -59,10 +59,11 @@ export function SendouButton({ }} className={buttonClassName({ className, variant, size, shape })} > - {icon && - React.cloneElement(icon, { - className: iconClassName(icon.props.className, children, size), - })} + {icon + ? React.cloneElement(icon, { + className: iconClassName(icon.props.className, children, size), + }) + : null} {children} ); @@ -112,10 +113,11 @@ export function LinkButton({ data-testid={testId} aria-label={ariaLabel} > - {icon && - React.cloneElement(icon, { - className: iconClassName(icon.props.className, children, size), - })} + {icon + ? React.cloneElement(icon, { + className: iconClassName(icon.props.className, children, size), + }) + : null} {children} ); @@ -132,10 +134,11 @@ export function LinkButton({ onClick={onClick} aria-label={ariaLabel} > - {icon && - React.cloneElement(icon, { - className: iconClassName(icon.props.className, children, size), - })} + {icon + ? React.cloneElement(icon, { + className: iconClassName(icon.props.className, children, size), + }) + : null} {children} ); diff --git a/app/components/layout/NotificationPopover.tsx b/app/components/layout/NotificationPopover.tsx index 82e609743..0fc2cd84f 100644 --- a/app/components/layout/NotificationPopover.tsx +++ b/app/components/layout/NotificationPopover.tsx @@ -116,7 +116,9 @@ export function NotificationContent({ seen: Number(!stickyUnseenIds.has(notification.id)), }} /> - {i !== notifications.length - 1 && } + {i !== notifications.length - 1 ? ( + + ) : null} ))} diff --git a/app/features/build-analyzer/routes/analyzer.tsx b/app/features/build-analyzer/routes/analyzer.tsx index 32b73bc0f..ec0449553 100644 --- a/app/features/build-analyzer/routes/analyzer.tsx +++ b/app/features/build-analyzer/routes/analyzer.tsx @@ -167,7 +167,7 @@ function BuildAnalyzerPage() { }; const mainWeaponCategoryItems = [ - analyzed.stats.shotSpreadAir && ( + analyzed.stats.shotSpreadAir ? ( - ), - typeof analyzed.stats.shotSpreadGround === "number" && ( + ) : null, + typeof analyzed.stats.shotSpreadGround === "number" ? ( - ), + ) : null, // Squeezer - analyzed.stats.shotAutofireSpreadAir && ( + analyzed.stats.shotAutofireSpreadAir ? ( - ), - typeof analyzed.stats.shotAutofireSpreadGround === "number" && ( + ) : null, + typeof analyzed.stats.shotAutofireSpreadGround === "number" ? ( - ), + ) : null, ...INK_CONSUME_TYPES.filter( (type) => analyzed.stats[`mainWeaponInkConsumptionPercentage_${type}`], @@ -216,7 +216,7 @@ function BuildAnalyzerPage() { suffix="%" /> )), - typeof analyzed.stats.mainWeaponWhiteInkSeconds === "number" && ( + typeof analyzed.stats.mainWeaponWhiteInkSeconds === "number" ? ( - ), - typeof analyzed.weapon.brellaCanopyHp === "number" && ( + ) : null, + typeof analyzed.weapon.brellaCanopyHp === "number" ? ( - ), - typeof analyzed.weapon.fullChargeSeconds === "number" && ( + ) : null, + typeof analyzed.weapon.fullChargeSeconds === "number" ? ( - ), - typeof analyzed.weapon.maxChargeHoldSeconds === "number" && ( + ) : null, + typeof analyzed.weapon.maxChargeHoldSeconds === "number" ? ( - ), + ) : null, ].filter(Boolean); // a primary slot-only ability (e.g. Ninja Squid) or Ability Doubler alone leaves abilityPoints at 0 @@ -313,47 +313,46 @@ function BuildAnalyzerPage() { {t("analyzer:compare")} - {[1, 2].map( - (buildIndex) => - focusedBuild && ( - - { - const firstBuildIsEmpty = build + {[1, 2].map((buildIndex) => + focusedBuild ? ( + + { + const firstBuildIsEmpty = build + .flat() + .every((ability) => ability === "UNKNOWN"); + + const buildWasEmptied = + !firstBuildIsEmpty && + newBuild .flat() - .every((ability) => ability === "UNKNOWN"); - - const buildWasEmptied = - !firstBuildIsEmpty && - newBuild - .flat() - .every((ability) => ability === "UNKNOWN") && - focused === 1; - - // otherwise build2 would be duplicated - if (buildWasEmptied) { - handleChange({ - newBuild: build2, - newBuild2: newBuild, - newFocused: 1, - }); - return; - } + .every((ability) => ability === "UNKNOWN") && + focused === 1; + // otherwise build2 would be duplicated + if (buildWasEmptied) { handleChange({ - [focused === 1 || firstBuildIsEmpty - ? "newBuild" - : "newBuild2"]: newBuild, - newFocused: firstBuildIsEmpty ? 1 : undefined, + newBuild: build2, + newBuild2: newBuild, + newFocused: 1, }); - }} - /> - - ), + return; + } + + handleChange({ + [focused === 1 || firstBuildIsEmpty + ? "newBuild" + : "newBuild2"]: newBuild, + newFocused: firstBuildIsEmpty ? 1 : undefined, + }); + }} + /> + + ) : null, )} - {showAbilityChunksRequired && ( + {showAbilityChunksRequired ? ( - )} + ) : null}
{t("analyzer:patch")} {CURRENT_PATCH}
- {mainWeaponCategoryItems.length > 0 && ( + {mainWeaponCategoryItems.length > 0 ? ( {mainWeaponCategoryItems} - )} + ) : null} - {analyzed.stats.subVelocity && ( + {analyzed.stats.subVelocity ? ( - )} - {analyzed.stats.subFirstPhaseDuration && ( + ) : null} + {analyzed.stats.subFirstPhaseDuration ? ( - )} - {analyzed.stats.subSecondPhaseDuration && ( + ) : null} + {analyzed.stats.subSecondPhaseDuration ? ( - )} - {analyzed.stats.subMarkingTimeInSeconds && ( + ) : null} + {analyzed.stats.subMarkingTimeInSeconds ? ( - )} - {analyzed.stats.subMarkingRadius && ( + ) : null} + {analyzed.stats.subMarkingRadius ? ( - )} - {analyzed.stats.subExplosionRadius && ( + ) : null} + {analyzed.stats.subExplosionRadius ? ( - )} - {analyzed.stats.subHp && ( + ) : null} + {analyzed.stats.subHp ? ( - )} - {analyzed.stats.subQsjBoost && ( + ) : null} + {analyzed.stats.subQsjBoost ? ( - )} + ) : null} )) : null} - {analyzed.stats.specialDurationInSeconds && ( + {analyzed.stats.specialDurationInSeconds ? ( - )} - {analyzed.stats.specialDamageDistance && ( + ) : null} + {analyzed.stats.specialDamageDistance ? ( - )} - {analyzed.stats.specialPaintRadius && ( + ) : null} + {analyzed.stats.specialPaintRadius ? ( - )} - {analyzed.stats.specialFieldHp && ( + ) : null} + {analyzed.stats.specialFieldHp ? ( - )} - {analyzed.stats.specialDeviceHp && ( + ) : null} + {analyzed.stats.specialDeviceHp ? ( - )} - {analyzed.stats.specialHookInkConsumptionPercentage && ( + ) : null} + {analyzed.stats.specialHookInkConsumptionPercentage ? ( - )} - {analyzed.stats.specialInkConsumptionPerSecondPercentage && ( + ) : null} + {analyzed.stats.specialInkConsumptionPerSecondPercentage ? ( - )} - {analyzed.stats.specialReticleRadius && ( + ) : null} + {analyzed.stats.specialReticleRadius ? ( - )} - {analyzed.stats.specialThrowDistance && ( + ) : null} + {analyzed.stats.specialThrowDistance ? ( - )} - {analyzed.stats.specialMoveSpeed && ( + ) : null} + {analyzed.stats.specialMoveSpeed ? ( - )} - {analyzed.stats.specialAutoChargeRate && ( + ) : null} + {analyzed.stats.specialAutoChargeRate ? ( - )} - {analyzed.stats.specialMaxRadius && ( + ) : null} + {analyzed.stats.specialMaxRadius ? ( - )} - {analyzed.stats.specialRadiusRangeMin && ( + ) : null} + {analyzed.stats.specialRadiusRangeMin ? ( - )} - {analyzed.stats.specialRadiusRangeMax && ( + ) : null} + {analyzed.stats.specialRadiusRangeMax ? ( - )} - {analyzed.stats.specialPowerUpDuration && ( + ) : null} + {analyzed.stats.specialPowerUpDuration ? ( - )} + ) : null} - {analyzed.stats.subWeaponDefenseDamages.length > 0 && ( + {analyzed.stats.subWeaponDefenseDamages.length > 0 ? ( - )} + ) : null} - {analyzed.stats.damages.length > 0 && ( + {analyzed.stats.damages.length > 0 ? ( - )} + ) : null} - {analyzed.stats.specialWeaponDamages.length > 0 && ( + {analyzed.stats.specialWeaponDamages.length > 0 ? ( - )} + ) : null} - {analyzed.stats.fullInkTankOptions.length > 0 && ( + {analyzed.stats.fullInkTankOptions.length > 0 ? (
- )} + ) : null} - {analyzed.stats.shootingRunSpeed && ( + {analyzed.stats.shootingRunSpeed ? ( - )} - {analyzed.stats.shootingRunSpeedCharging && ( + ) : null} + {analyzed.stats.shootingRunSpeedCharging ? ( - )} - {analyzed.stats.shootingRunSpeedFullCharge && ( + ) : null} + {analyzed.stats.shootingRunSpeedFullCharge ? ( - )} - {analyzed.stats.shootingRunSpeedSecondaryMode && ( + ) : null} + {analyzed.stats.shootingRunSpeedSecondaryMode ? ( - )} + ) : null} - {objectShredderSelected && ( + {objectShredderSelected ? ( {t("analyzer:objCalcAd")} - )} + ) : null} {user && focusedBuild && !buildIsEmpty(focusedBuild) ? (
{children}
- {textBelow && ( + {textBelow ? (
{textBelow}
- )} + ) : null} ); } @@ -1540,7 +1539,7 @@ function StatCard({

{title}{" "} - {popoverInfo && ( + {popoverInfo ? ( @@ -1550,7 +1549,7 @@ function StatCard({ > {popoverInfo} - )} + ) : null}

@@ -1684,9 +1683,9 @@ function DamageTable({ {t("analyzer:damage.header.type")} - {showDistanceColumn && ( + {showDistanceColumn ? ( {t("analyzer:damage.header.distance")} - )} + ) : null} {damageIsSubWeaponDamage(firstRow) ? ( {comparisonValues @@ -1694,7 +1693,9 @@ function DamageTable({ : t("analyzer:damage.header.baseDamage")} ) : null} - {showDamageColumn && {t("analyzer:damage.header.damage")}} + {showDamageColumn ? ( + {t("analyzer:damage.header.damage")} + ) : null} {showPopovers ? : null} @@ -1731,27 +1732,27 @@ function DamageTable({ ) : null}
- {showDistanceColumn && ( + {showDistanceColumn ? ( {typeof val.distance === "number" ? val.distance : val.distance?.join("-")} - )} - {damageIsSubWeaponDamage(val) && {val.baseValue}} - {showDamageColumn && ( + ) : null} + {damageIsSubWeaponDamage(val) ? {val.baseValue} : null} + {showDamageColumn ? ( {damage(val)} {comparisonVal ? `/${damage(comparisonVal)}` : null}{" "} - {val.shotsToSplat && ( + {val.shotsToSplat ? ( {t("analyzer:damage.toSplat", { count: val.shotsToSplat, })} - )} + ) : null} - )} + ) : null} {showPopovers ? ( {renderPopover(val, (val as SubWeaponDamage).subWeaponId) ? ( @@ -1858,11 +1859,11 @@ function ConsumptionTable({ })} - {subWeaponId === TORPEDO_ID && ( + {subWeaponId === TORPEDO_ID ? (
{t("analyzer:torpedoExplanation")}
- )} + ) : null} ); } diff --git a/app/features/calendar/routes/calendar.$id.tsx b/app/features/calendar/routes/calendar.$id.tsx index 813baa78c..4fc92c673 100644 --- a/app/features/calendar/routes/calendar.$id.tsx +++ b/app/features/calendar/routes/calendar.$id.tsx @@ -191,7 +191,7 @@ function Results() { return (
- {data.event.participantCount && ( + {data.event.participantCount ? (
{isTeamResults ? t("calendar:participatedCount", { @@ -201,7 +201,7 @@ function Results() { count: data.event.participantCount, })}
- )} + ) : null} @@ -276,9 +276,9 @@ function Description() { {data.event.username} - {data.event.description && ( + {data.event.description ? (
{data.event.description}
- )} + ) : null} ); diff --git a/app/features/lfg/components/LFGPost.tsx b/app/features/lfg/components/LFGPost.tsx index 9cc8b8442..431f732ce 100644 --- a/app/features/lfg/components/LFGPost.tsx +++ b/app/features/lfg/components/LFGPost.tsx @@ -92,7 +92,9 @@ function TeamLFGPost({
- {isHydrated && } + {isHydrated ? ( + + ) : null} {post.languages ? ( ) : null} @@ -273,12 +275,12 @@ function PostPills({ invisible: !isHydrated, })} > - {typeof timezone === "string" && isHydrated && ( + {typeof timezone === "string" && isHydrated ? ( - )} - {!isHydrated && } + ) : null} + {!isHydrated ? : null} {languages ? : null} - {canEdit && } + {canEdit ? : null}
); } diff --git a/app/features/map-list-generator/routes/maps.tsx b/app/features/map-list-generator/routes/maps.tsx index 10dd21ff8..72708d26c 100644 --- a/app/features/map-list-generator/routes/maps.tsx +++ b/app/features/map-list-generator/routes/maps.tsx @@ -128,7 +128,7 @@ function MapListCreator({ mapPool }: { mapPool: MapPool }) { {t("common:maps.createMapList")} - {mapList && ( + {mapList ? ( <>
    {mapList.map(({ mode, stageId }, i) => ( @@ -163,7 +163,7 @@ function MapListCreator({ mapPool }: { mapPool: MapPool }) { {t("common:actions.copyToClipboard")} - )} + ) : null}
); } diff --git a/app/features/notifications/routes/notifications.tsx b/app/features/notifications/routes/notifications.tsx index 05f25b4e1..3f0e893a8 100644 --- a/app/features/notifications/routes/notifications.tsx +++ b/app/features/notifications/routes/notifications.tsx @@ -61,9 +61,9 @@ export default function NotificationsPage() { seen: Number(!unseenIds.has(notification.id)), }} /> - {i !== data.notifications.length - 1 && ( + {i !== data.notifications.length - 1 ? ( - )} + ) : null} ))} diff --git a/app/features/object-damage-calculator/routes/object-damage-calculator.tsx b/app/features/object-damage-calculator/routes/object-damage-calculator.tsx index d8ffe8653..99a08f620 100644 --- a/app/features/object-damage-calculator/routes/object-damage-calculator.tsx +++ b/app/features/object-damage-calculator/routes/object-damage-calculator.tsx @@ -321,7 +321,9 @@ function DamageReceiversGrid({
{t(`analyzer:damage.${damage.type}` as any)} - {damage.objectShredder && } + {damage.objectShredder ? ( + + ) : null}
))} @@ -332,8 +334,9 @@ function DamageReceiversGrid({
{t("q:looking.columns.groups")} - {isMobile && ( + {isMobile ? ( - )} - {isMobile && data.ownGroup && ( + ) : null} + {isMobile && data.ownGroup ? ( {t("q:looking.columns.myGroup")} - )} + ) : null}
diff --git a/app/features/tournament-bracket/components/Bracket/Swiss.tsx b/app/features/tournament-bracket/components/Bracket/Swiss.tsx index 2933cc840..e6510030e 100644 --- a/app/features/tournament-bracket/components/Bracket/Swiss.tsx +++ b/app/features/tournament-bracket/components/Bracket/Swiss.tsx @@ -98,7 +98,7 @@ export function SwissBracket({ return (
- {groups.length > 1 && ( + {groups.length > 1 ? (
{groups.map((g) => ( ))}
- )} + ) : null}
{rounds.map((round, roundI) => { const matches = bracket.data.match.filter( diff --git a/app/features/tournament-bracket/routes/to.$id.brackets.finalize.tsx b/app/features/tournament-bracket/routes/to.$id.brackets.finalize.tsx index 5a1164248..2b4a44d62 100644 --- a/app/features/tournament-bracket/routes/to.$id.brackets.finalize.tsx +++ b/app/features/tournament-bracket/routes/to.$id.brackets.finalize.tsx @@ -293,9 +293,9 @@ function NewBadgeReceiversSelector({
- {i !== standingToReceive?.members.length - 1 && ( + {i !== standingToReceive?.members.length - 1 ? ( - )} + ) : null}
); })} diff --git a/app/features/tournament-organization/routes/org.$slug.tsx b/app/features/tournament-organization/routes/org.$slug.tsx index f6acf32cf..f8014d7e3 100644 --- a/app/features/tournament-organization/routes/org.$slug.tsx +++ b/app/features/tournament-organization/routes/org.$slug.tsx @@ -424,12 +424,12 @@ function SeriesView({
- {hasLeaderboard && ( + {hasLeaderboard ? ( - )} + ) : null}
diff --git a/app/features/user-page/components/UserResultsTable.tsx b/app/features/user-page/components/UserResultsTable.tsx index 958f9c818..0027a3c96 100644 --- a/app/features/user-page/components/UserResultsTable.tsx +++ b/app/features/user-page/components/UserResultsTable.tsx @@ -37,7 +37,7 @@ export function UserResultsTable({
- {hasHighlightCheckboxes && @@ -59,7 +59,7 @@ export function UserResultsTable({ return ( - {hasHighlightCheckboxes && ( + {hasHighlightCheckboxes ? ( - )} + ) : null}
} + {hasHighlightCheckboxes ? : null} {t("results.placing")} {t("results.tournament")} {t("results.date")}
{" "} diff --git a/app/root.tsx b/app/root.tsx index ad6c5c0fa..4fd550ba3 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -259,7 +259,7 @@ function Document({ - {IS_E2E_TEST_RUN && } + {IS_E2E_TEST_RUN ? : null} diff --git a/biome-plugins/no-and-conditional-rendering.grit b/biome-plugins/no-and-conditional-rendering.grit new file mode 100644 index 000000000..12fc1bbda --- /dev/null +++ b/biome-plugins/no-and-conditional-rendering.grit @@ -0,0 +1,18 @@ +language js + +// `cond && ` renders the left value when it is falsy but not `null` / +// `undefined` / `false`: a `0` count or an empty string ends up in the DOM as +// text. The ternary form has no such edge, so it is the repo-wide convention +// (AGENTS.md, "React"). Matches both `cond && ` anywhere and any `&&` +// standing directly as a JSX child, which covers helpers like +// `{icon && React.cloneElement(icon, {...})}`. +`$cond && $rendered` as $expression where { + or { + $rendered <: or { + JsxTagExpression(), + JsParenthesizedExpression(expression=JsxTagExpression()) + }, + $expression <: within JsxExpressionChild(expression=$expression) + }, + register_diagnostic(span=$expression, message="Do not use `&&` for conditional rendering: a falsy left side that is not `null`/`undefined`/`false` (a `0` count, an empty string, `NaN`) renders itself. Use a ternary instead: `cond ? : null`.", severity="error") +} diff --git a/biome.json b/biome.json index 70759a085..f8c0e0c7b 100644 --- a/biome.json +++ b/biome.json @@ -221,6 +221,10 @@ { "includes": ["**", "!app/utils/kysely.server.ts"], "plugins": ["./biome-plugins/no-kysely-sqlite-helpers.grit"] + }, + { + "includes": ["app/**"], + "plugins": ["./biome-plugins/no-and-conditional-rendering.grit"] } ] }