diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ddccc4689..9ecd0df13c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,18 @@ jobs: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT - name: Setup pnpm cache + # only one task can write to cache at the same time, we make format do that + if: ${{ matrix.task != 'format' }} + uses: actions/cache/restore@v4 + with: + path: ${{ steps.get-store-path.outputs.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Setup pnpm cache for `format` (and write to it) + # only one task can write to cache at the same time, we make format do that + if: ${{ matrix.task == 'format' }} uses: actions/cache@v4 with: path: ${{ steps.get-store-path.outputs.STORE_PATH }} @@ -56,13 +68,15 @@ jobs: ${{ runner.os }}-pnpm-store- # to cache p:build, format, lint, type-check and test-run + # we need to use the task name to avoid conflicts between different tasks + # that use the same cache key - name: Setup turbo cache uses: actions/cache@v4 with: path: .turbo - key: ${{ runner.os }}-turbo-${{ github.sha }} + key: ${{ runner.os }}-turbo-${{ matrix.task }}-${{ github.sha }} restore-keys: | - ${{ runner.os }}-turbo- + ${{ runner.os }}-turbo-${{ matrix.task }}- - name: Install dependencies run: pnpm install --frozen-lockfile --prefer-offline diff --git a/babel.config.js b/babel.config.js index 9419da6638..504f376c71 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,3 +1,4 @@ module.exports = { presets: ["@babel/preset-env", "@babel/preset-react", "@babel/preset-typescript"], + plugins: ["babel-plugin-react-compiler"], }; diff --git a/config/eslint/tsconfig.json b/config/eslint/tsconfig.json index 2030b6b3f7..463376825a 100644 --- a/config/eslint/tsconfig.json +++ b/config/eslint/tsconfig.json @@ -5,6 +5,6 @@ "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json", "typeRoots": ["node_modules/@types", "./types.d.ts"] }, - "include": ["."], + "include": ["./**.js", "./**.d.ts"], "exclude": ["node_modules"] } diff --git a/core/.env.development b/core/.env.development index c30d247cc9..a060deda86 100644 --- a/core/.env.development +++ b/core/.env.development @@ -25,4 +25,6 @@ DATACITE_PASSWORD="" DATACITE_API_URL="https://api.test.datacite.org" GCLOUD_KEY_FILE='xxx' -VALKEY_HOST='localhost' \ No newline at end of file +VALKEY_HOST='localhost' + +REACT_SCAN_ENABLED=true \ No newline at end of file diff --git a/core/actions/http/config/client-components/FieldOutputMap.tsx b/core/actions/http/config/client-components/FieldOutputMap.tsx index 40e528b17d..555e65a753 100644 --- a/core/actions/http/config/client-components/FieldOutputMap.tsx +++ b/core/actions/http/config/client-components/FieldOutputMap.tsx @@ -1,6 +1,6 @@ "use client"; -import { useFieldArray } from "react-hook-form"; +import { useFieldArray, useWatch } from "react-hook-form"; import type { PubFieldSchemaId, PubFieldsId } from "db/public"; import { AccordionContent, AccordionItem, AccordionTrigger } from "ui/accordion"; @@ -121,7 +121,7 @@ export const FieldOutputMap = defineCustomFormField( "outputMap", function FieldOutputMap({ form, fieldName }) { const pubFields = Object.values(usePubFieldContext()); - const values = form.watch(); + const values = useWatch({ control: form.control, name: fieldName }); const { fields, append, remove } = useFieldArray({ control: form.control, @@ -131,7 +131,7 @@ export const FieldOutputMap = defineCustomFormField( const [title] = itemName.split("|"); - const alreadySelectedPubFields = values[fieldName] ?? []; + const alreadySelectedPubFields = values ?? []; const unselectedPubFields = pubFields.filter( (pubField) => !alreadySelectedPubFields.some((field) => field.pubField === pubField.slug) diff --git a/core/app/(user)/forgot/ForgotForm.tsx b/core/app/(user)/forgot/ForgotForm.tsx index 1b3389a3f3..5e7d5db908 100644 --- a/core/app/(user)/forgot/ForgotForm.tsx +++ b/core/app/(user)/forgot/ForgotForm.tsx @@ -18,7 +18,7 @@ const forgotPasswordSchema = z.object({ email: z.string().email(), }); -export default function ForgotForm() { +export function ForgotForm() { const form = useForm>({ resolver: zodResolver(forgotPasswordSchema), defaultValues: { diff --git a/core/app/(user)/forgot/page.tsx b/core/app/(user)/forgot/page.tsx index 687139cb39..1b0aa67cc2 100644 --- a/core/app/(user)/forgot/page.tsx +++ b/core/app/(user)/forgot/page.tsx @@ -1,6 +1,18 @@ +import type { Metadata } from "next"; + +import React from "react"; + import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "ui/card"; -import ForgotForm from "./ForgotForm"; +import { ForgotForm } from "./ForgotForm"; + +export const dynamic = "force-dynamic"; + +export const metadata: Metadata = { + title: "Forgot password", + description: + "Enter your account's email address below to receive a secure link for resetting your password.", +}; export default async function Page() { return ( diff --git a/core/app/(user)/settings/page.tsx b/core/app/(user)/settings/page.tsx index 173fd10539..22261ff69c 100644 --- a/core/app/(user)/settings/page.tsx +++ b/core/app/(user)/settings/page.tsx @@ -19,6 +19,8 @@ export default async function Page() { communityId: community.id, redirectUrl: await constructRedirectToBaseCommunityPage({ communitySlug: community.slug, + user, + community, }), })) ); diff --git a/core/app/c/[communitySlug]/CommunitySwitcher.tsx b/core/app/c/[communitySlug]/CommunitySwitcher.tsx index f975900213..e5e9aeca51 100644 --- a/core/app/c/[communitySlug]/CommunitySwitcher.tsx +++ b/core/app/c/[communitySlug]/CommunitySwitcher.tsx @@ -1,3 +1,5 @@ +import type { User } from "lucia"; + import Link from "next/link"; import { Avatar, AvatarFallback, AvatarImage } from "ui/avatar"; @@ -17,9 +19,14 @@ import { constructRedirectToBaseCommunityPage } from "~/lib/server/navigation/re type Props = { community: NonNullable; availableCommunities: NonNullable[]; + user: User; }; -const CommunitySwitcher: React.FC = async function ({ community, availableCommunities }) { +const CommunitySwitcher: React.FC = async function ({ + community, + availableCommunities, + user, +}) { const avatarClasses = "rounded-md w-9 h-9 mr-1 group-data-[collapsible=icon]:h-8 group-data-[collapsible=icon]:w-8 border"; const textClasses = "flex-auto text-base font-semibold w-44 text-left"; @@ -30,7 +37,11 @@ const CommunitySwitcher: React.FC = async function ({ community, availabl const communityRedirectUrls = await Promise.all( availableCommunities.map(async (option) => ({ communityId: option.id, - redirectUrl: await constructRedirectToBaseCommunityPage({ communitySlug: option.slug }), + redirectUrl: await constructRedirectToBaseCommunityPage({ + communitySlug: option.slug, + community, + user, + }), })) ); diff --git a/core/app/c/[communitySlug]/SideNav.tsx b/core/app/c/[communitySlug]/SideNav.tsx index cadff4cd4d..4cc6d8ebe8 100644 --- a/core/app/c/[communitySlug]/SideNav.tsx +++ b/core/app/c/[communitySlug]/SideNav.tsx @@ -351,6 +351,7 @@ const SideNav: React.FC = async function ({ community, availableCommuniti diff --git a/core/app/c/[communitySlug]/fields/FieldForm.tsx b/core/app/c/[communitySlug]/fields/FieldForm.tsx index 84645f20f2..b48da24e98 100644 --- a/core/app/c/[communitySlug]/fields/FieldForm.tsx +++ b/core/app/c/[communitySlug]/fields/FieldForm.tsx @@ -3,7 +3,7 @@ import type { UseFormReturn } from "react-hook-form"; import { useCallback, useEffect } from "react"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; +import { useForm, useWatch } from "react-hook-form"; import { SCHEMA_TYPES_WITH_ICONS } from "schemas"; import { z } from "zod"; @@ -71,7 +71,7 @@ const DEFAULT_VALUES = { isRelation: false, } as unknown as DefaultFieldFormValues; -type FormType = UseFormReturn; +type FormType = UseFormReturn; const SchemaSelectField = ({ form, isDisabled }: { form: FormType; isDisabled?: boolean }) => { const schemaTypes = Object.values(CoreSchemaType).filter((v) => v !== CoreSchemaType.Null); @@ -146,9 +146,8 @@ const SlugField = ({ communitySlug: string; readOnly?: boolean; }) => { - const { watch, setValue } = form; - - const watchName = watch("name"); + const { setValue } = form; + const watchName = useWatch({ control: form.control, name: "name" }); useEffect(() => { if (!readOnly) { diff --git a/core/app/c/[communitySlug]/settings/tokens/CreateTokenForm.tsx b/core/app/c/[communitySlug]/settings/tokens/CreateTokenForm.tsx index fc0401a108..10db69f3f4 100644 --- a/core/app/c/[communitySlug]/settings/tokens/CreateTokenForm.tsx +++ b/core/app/c/[communitySlug]/settings/tokens/CreateTokenForm.tsx @@ -2,7 +2,7 @@ import React from "react"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; +import { useForm, useWatch } from "react-hook-form"; import { z } from "zod"; import type { CreateTokenFormContext as CreateTokenFormContextType } from "db/types"; @@ -81,7 +81,7 @@ export const CreateTokenForm = () => { } }; // this `as const` should not be necessary, not sure why it is - const token = form.watch("token" as const); + const token = useWatch({ control: form.control, name: "token" }); return (
diff --git a/core/app/c/[communitySlug]/stages/manage/components/panel/actionsTab/StagePanelRuleCreator.tsx b/core/app/c/[communitySlug]/stages/manage/components/panel/actionsTab/StagePanelRuleCreator.tsx index 968da44456..7f2af2fe44 100644 --- a/core/app/c/[communitySlug]/stages/manage/components/panel/actionsTab/StagePanelRuleCreator.tsx +++ b/core/app/c/[communitySlug]/stages/manage/components/panel/actionsTab/StagePanelRuleCreator.tsx @@ -4,7 +4,7 @@ import type { ControllerRenderProps, FieldValue, UseFormReturn } from "react-hoo import { useCallback, useState } from "react"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; +import { useForm, useWatch } from "react-hook-form"; import { z } from "zod"; import type { @@ -199,9 +199,12 @@ export const StagePanelRuleCreator = (props: Props) => { }, }); - const event = form.watch("event"); - const selectedActionInstanceId = form.watch("actionInstanceId"); - const sourceActionInstanceId = form.watch("sourceActionInstanceId"); + const event = useWatch({ control: form.control, name: "event" }); + const selectedActionInstanceId = useWatch({ control: form.control, name: "actionInstanceId" }); + const sourceActionInstanceId = useWatch({ + control: form.control, + name: "sourceActionInstanceId", + }); // for action chaining events, filter out self-references const isActionChainingEvent = event === Event.actionSucceeded || event === Event.actionFailed; diff --git a/core/app/compiler-demo/page.tsx b/core/app/compiler-demo/page.tsx new file mode 100644 index 0000000000..dad23de301 --- /dev/null +++ b/core/app/compiler-demo/page.tsx @@ -0,0 +1,95 @@ +"use client"; + +import React, { useState } from "react"; + +function SlowComponent(props: { unused?: any }) { + const largeArray = Array.from({ length: 10000 }, (_, i) => i); + + return ( +
+ {largeArray.map((value) => ( +
+ ))} +
+ ); +} + +function CounterButton(props: { onClick: () => void }) { + return ( + + ); +} + +function ColorPicker(props: { value: string; onChange: (value: string) => void }) { + return ( + props.onChange(e.target.value)} + className="h-12 w-full cursor-pointer rounded border border-white/20 bg-neutral-700 p-1" + /> + ); +} + +export function DemoComponent() { + const [count, setCount] = useState(0); + const [color, setColor] = useState("#ffffff"); + + return ( +
+
+

Color Picker

+ setColor(e)} /> +
+ Current value:
+ {color} +
+
+
+

Counter

+ setCount((count) => count + 1)} /> +
+ Current value:
+ {count} +
+
+
+

A Slow Component

+ + (This component renders 10,000 boxes) + + +
+
+ ); +} +export default function Demo() { + const [color, setColor] = useState("#ffffff"); + return ( +
+

React Compiler Demo

+

+ Turn off the compiler in next.config.ts to see the difference. +

+

+ Picking a color should be really slow with the compiler turned off +

+ +
+ + setColor(e)} /> +
+
+ ); +} diff --git a/core/app/components/FormBuilder/ElementPanel/ButtonConfigurationForm.tsx b/core/app/components/FormBuilder/ElementPanel/ButtonConfigurationForm.tsx index 6675d472ce..f9792e1bcd 100644 --- a/core/app/components/FormBuilder/ElementPanel/ButtonConfigurationForm.tsx +++ b/core/app/components/FormBuilder/ElementPanel/ButtonConfigurationForm.tsx @@ -1,6 +1,6 @@ import { useMemo } from "react"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm, useFormContext } from "react-hook-form"; +import { useForm, useFormContext, useWatch } from "react-hook-form"; import { z } from "zod"; import type { StagesId } from "db/public"; @@ -117,7 +117,8 @@ export const ButtonConfigurationForm = ({ }); dispatch({ eventName: "save" }); }; - const labelValue = form.watch("label"); + + const labelValue = useWatch({ control: form.control, name: "label" }); return ( diff --git a/core/app/components/FormBuilder/ElementPanel/ComponentConfig/ColorPicker.tsx b/core/app/components/FormBuilder/ElementPanel/ComponentConfig/ColorPicker.tsx index f8a9bbcc63..d44ed91c5c 100644 --- a/core/app/components/FormBuilder/ElementPanel/ComponentConfig/ColorPicker.tsx +++ b/core/app/components/FormBuilder/ElementPanel/ComponentConfig/ColorPicker.tsx @@ -2,7 +2,7 @@ import type { ControllerRenderProps } from "react-hook-form"; import { useEffect, useState } from "react"; import { Pencil, PlusIcon, TrashIcon } from "lucide-react"; -import { useFormContext } from "react-hook-form"; +import { useFormContext, useWatch } from "react-hook-form"; import type { InputComponent } from "db/public"; import { Button } from "ui/button"; @@ -128,8 +128,8 @@ export const FormBuilderColorPickerPopover = ({ export default (props: ComponentConfigFormProps) => { // for some reason if i use `props.form` the watched values don't update when the form values change const reactiveForm = useFormContext>(); - const presets = reactiveForm.watch("config.presets"); - const presetsOnly = reactiveForm.watch("config.presetsOnly"); + const presets = useWatch({ control: reactiveForm.control, name: "config.presets" }); + const presetsOnly = useWatch({ control: reactiveForm.control, name: "config.presetsOnly" }); const presetsOnlyEnabled = Boolean(presets?.length); diff --git a/core/app/components/FormBuilder/ElementPanel/InputComponentConfigurationForm.tsx b/core/app/components/FormBuilder/ElementPanel/InputComponentConfigurationForm.tsx index 768a2d8b1e..570c00873a 100644 --- a/core/app/components/FormBuilder/ElementPanel/InputComponentConfigurationForm.tsx +++ b/core/app/components/FormBuilder/ElementPanel/InputComponentConfigurationForm.tsx @@ -7,7 +7,7 @@ import dynamic from "next/dynamic"; import { typeboxResolver } from "@hookform/resolvers/typebox"; import { Type } from "@sinclair/typebox"; import { Value } from "@sinclair/typebox/value"; -import { useForm } from "react-hook-form"; +import { useForm, useWatch } from "react-hook-form"; import { componentConfigSchemas, componentsBySchema, relationBlockConfigSchema } from "schemas"; import type { PubsId, PubTypesId } from "db/public"; @@ -348,7 +348,7 @@ export const InputComponentConfigurationForm = ({ index, fieldInputElement }: Pr useUnsavedChangesWarning(form.formState.isDirty); - const component = form.watch("component"); + const component = useWatch({ control: form.control, name: "component" }); const onSubmit = (values: ConfigFormData) => { const schema = isRelation diff --git a/core/app/components/Memberships/MemberInviteForm.tsx b/core/app/components/Memberships/MemberInviteForm.tsx index 992261d4a1..15f9fedb5e 100644 --- a/core/app/components/Memberships/MemberInviteForm.tsx +++ b/core/app/components/Memberships/MemberInviteForm.tsx @@ -5,7 +5,7 @@ import type { z } from "zod"; import { useEffect, useMemo } from "react"; import { zodResolver } from "@hookform/resolvers/zod"; import { skipToken } from "@tanstack/react-query"; -import { useForm } from "react-hook-form"; +import { useForm, useWatch } from "react-hook-form"; import { MemberRole, MembershipType } from "db/public"; import { Avatar, AvatarFallback, AvatarImage } from "ui/avatar"; @@ -66,7 +66,7 @@ export const MemberInviteForm = ({ }, mode: "onChange", }); - const email = form.watch("email"); + const email = useWatch({ control: form.control, name: "email" }); const emailState = form.getFieldState("email", form.formState); const query = { email, limit: 1, communityId: community.id }; const shouldSearch = email && (!emailState.error || emailState.error.type === "alreadyMember"); @@ -139,7 +139,8 @@ export const MemberInviteForm = ({ } } - const isContributor = form.watch("role") === MemberRole.contributor; + const contributorRole = useWatch({ control: form.control, name: "role" }); + const isContributor = contributorRole === MemberRole.contributor; return ( diff --git a/core/app/components/__tests__/CheckboxGroupElement.test.tsx b/core/app/components/__tests__/CheckboxGroupElement.test.tsx index 6e906f56a5..100c2afe5c 100644 --- a/core/app/components/__tests__/CheckboxGroupElement.test.tsx +++ b/core/app/components/__tests__/CheckboxGroupElement.test.tsx @@ -7,7 +7,7 @@ import { typeboxResolver } from "@hookform/resolvers/typebox"; import { Type } from "@sinclair/typebox"; import { act, fireEvent, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { useForm } from "react-hook-form"; +import { useForm, useWatch } from "react-hook-form"; import { checkboxGroupConfigSchema, MinMaxChoices } from "schemas"; import { getNumericArrayWithMinMax, getStringArrayWithMinMax } from "schemas/schemas"; import { expect, it, vi } from "vitest"; @@ -49,7 +49,7 @@ const FormWrapper = ({ resolver: typeboxResolver(schema), reValidateMode: "onBlur", }); - const values = form.watch("example"); + const values = useWatch({ control: form.control, name: "example" }); return ( diff --git a/core/app/components/__tests__/RadioGroupElement.test.tsx b/core/app/components/__tests__/RadioGroupElement.test.tsx index 220d57039d..1cbfcde94c 100644 --- a/core/app/components/__tests__/RadioGroupElement.test.tsx +++ b/core/app/components/__tests__/RadioGroupElement.test.tsx @@ -7,7 +7,7 @@ import { typeboxResolver } from "@hookform/resolvers/typebox"; import { Type } from "@sinclair/typebox"; import { act, fireEvent, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { useForm } from "react-hook-form"; +import { useForm, useWatch } from "react-hook-form"; import { checkboxGroupConfigSchema, radioGroupConfigSchema } from "schemas"; import { NumericArray, StringArray } from "schemas/schemas"; import { expect, it, vi } from "vitest"; @@ -45,7 +45,7 @@ const FormWrapper = ({ resolver: typeboxResolver(schema), reValidateMode: "onBlur", }); - const values = form.watch("example"); + const values = useWatch({ control: form.control, name: "example" }); return ( diff --git a/core/app/components/__tests__/SelectDropdownElement.test.tsx b/core/app/components/__tests__/SelectDropdownElement.test.tsx index 9fa034388b..704a8fb9ce 100644 --- a/core/app/components/__tests__/SelectDropdownElement.test.tsx +++ b/core/app/components/__tests__/SelectDropdownElement.test.tsx @@ -7,7 +7,7 @@ import { typeboxResolver } from "@hookform/resolvers/typebox"; import { Type } from "@sinclair/typebox"; import { act, fireEvent, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { useForm } from "react-hook-form"; +import { useForm, useWatch } from "react-hook-form"; import { checkboxGroupConfigSchema, radioGroupConfigSchema, @@ -49,7 +49,7 @@ const FormWrapper = ({ resolver: typeboxResolver(schema), reValidateMode: "onBlur", }); - const values = form.watch("example"); + const values = useWatch({ control: form.control, name: "example" }); return ( diff --git a/core/app/components/forms/elements/RelatedPubsElement.tsx b/core/app/components/forms/elements/RelatedPubsElement.tsx index 57d10c0de0..d283402245 100644 --- a/core/app/components/forms/elements/RelatedPubsElement.tsx +++ b/core/app/components/forms/elements/RelatedPubsElement.tsx @@ -15,7 +15,7 @@ import { import { CSS } from "@dnd-kit/utilities"; import { Value } from "@sinclair/typebox/value"; import { AlertTriangle } from "lucide-react"; -import { useFieldArray, useFormContext } from "react-hook-form"; +import { useFieldArray, useFormContext, useWatch } from "react-hook-form"; import { relationBlockConfigSchema } from "schemas"; import type { ProcessedPub } from "contracts"; @@ -140,9 +140,10 @@ export const ConfigureRelatedValue = ({ : element.config.label; const label = configLabel || element.label || element.slug; - const { watch, formState } = useFormContext(); + const { formState, control } = useFormContext(); const [isPopoverOpen, setPopoverIsOpen] = useState(false); - const value = watch(slug); + const value = useWatch({ control: control, name: slug }); + const showValue = value != null && value !== ""; const valueError = parseRelatedPubValuesSlugError(slug, formState.errors); diff --git a/core/app/layout.tsx b/core/app/layout.tsx index e9f6ab3c51..cc7a47a083 100644 --- a/core/app/layout.tsx +++ b/core/app/layout.tsx @@ -3,11 +3,13 @@ import { NuqsAdapter } from "nuqs/adapters/next/app"; import "ui/styles.css"; import { Suspense } from "react"; +import Script from "next/script"; import { KeyboardShortcutProvider } from "ui/hooks"; import { TooltipProvider } from "ui/tooltip"; import { getLoginData } from "~/lib/authentication/loginData"; +import { env } from "~/lib/env/env"; import { ReactQueryProvider } from "./components/providers/QueryProvider"; import { UserProvider } from "./components/providers/UserProvider"; import { RootToaster } from "./RootToaster"; @@ -22,6 +24,12 @@ export default async function RootLayout({ children }: { children: React.ReactNo return ( + {env.NODE_ENV === "development" && env.REACT_SCAN_ENABLED && ( +