From ae212100b08b4cc725549a365fa658d05319c7de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rnar=20=C3=98sttveit?= Date: Thu, 7 Nov 2024 17:31:17 +0100 Subject: [PATCH 01/25] optimize hook usage in expression data sources --- src/core/contexts/hookContext.tsx | 50 +++++++++ src/features/datamodel/DataModelsProvider.tsx | 52 ++++++--- src/features/language/useLanguage.ts | 22 +++- src/features/options/useGetOptionsQuery.ts | 4 +- src/hooks/useSourceOptions.ts | 4 +- src/utils/layout/NodesContext.tsx | 8 +- .../layout/generator/GeneratorDataSources.tsx | 104 ++++++++++++++++++ src/utils/layout/generator/NodeGenerator.tsx | 4 +- src/utils/layout/generator/debug.ts | 4 +- .../layout/generator/useEvalExpression.ts | 4 +- .../layout/useDataModelBindingTranspose.ts | 4 +- src/utils/layout/useNodeItem.ts | 4 + src/utils/layout/useNodeTraversal.ts | 11 ++ 13 files changed, 242 insertions(+), 33 deletions(-) create mode 100644 src/core/contexts/hookContext.tsx create mode 100644 src/utils/layout/generator/GeneratorDataSources.tsx diff --git a/src/core/contexts/hookContext.tsx b/src/core/contexts/hookContext.tsx new file mode 100644 index 0000000000..585792384c --- /dev/null +++ b/src/core/contexts/hookContext.tsx @@ -0,0 +1,50 @@ +import React, { createContext, useContext } from 'react'; +import type { PropsWithChildren } from 'react'; + +type HookContextProps = { + [name: string]: () => unknown; +}; + +/** + * This will store the result of any hook in a context and return a new hook for reading the context value. + * Why is this useful? React always reruns hooks on render, and if the hook does expensive calculations and + * is used a lot (e.g. for every node in the node generator), this will make things much faster, + * as reading from a plain context is very cheap. It is also much cheaper than subscribing directly to + * a zustand store. You shouldn't bother storing the result of another simple context here, as it will + * not be any faster. + */ +export function createHookContext

( + props: P, +): { + Provider: React.FC; + hooks: P; +} { + const data = Object.entries(props).map(([name, useHook]) => { + const Context = createContext(null as unknown); + Context.displayName = name; + const useCtx = () => useContext(Context); + const Provider = ({ children }: PropsWithChildren) => { + const value = useHook(); + return {children}; + }; + return { + name, + useCtx, + Provider, + }; + }); + + return { + Provider: ({ children }: PropsWithChildren) => ( + <> + {data.reduce( + (innerProviders, { Provider }) => ( + {innerProviders} + ), + <>{children}, + )} + + ), + hooks: Object.fromEntries(data.map(({ name, useCtx }) => [name, useCtx])) as P, + }; +} diff --git a/src/features/datamodel/DataModelsProvider.tsx b/src/features/datamodel/DataModelsProvider.tsx index cef6b30a3d..97e2fd0be2 100644 --- a/src/features/datamodel/DataModelsProvider.tsx +++ b/src/features/datamodel/DataModelsProvider.tsx @@ -112,7 +112,7 @@ function initialCreateStore() { })); } -const { Provider, useSelector, useMemoSelector, useLaxMemoSelector, useSelectorAsRef } = createZustandContext({ +const { Provider, useSelector, useLaxSelector, useSelectorAsRef } = createZustandContext({ name: 'DataModels', required: true, initialCreateStore, @@ -178,24 +178,39 @@ function DataModelsLoader() { setDataTypes(allValidDataTypes, writableDataTypes, defaultDataType); }, [applicationMetadata, defaultDataType, isStateless, layouts, setDataTypes, dataElements]); + const initialDataDone = useSelector((state) => Object.keys(state.initialData)); + const schemaDone = useSelector((state) => Object.keys(state.schemas)); + const validationConfigDone = useSelector((state) => Object.keys(state.expressionValidationConfigs)); + // We should load form data and schema for all referenced data models, schema is used for dataModelBinding validation which we want to do even if it is readonly // We only need to load expression validation config for data types that are not readonly. Additionally, backend will error if we try to validate a model we are not supposed to return ( <> - {allDataTypes?.map((dataType) => ( - + {allDataTypes + ?.filter((dataType) => !initialDataDone.includes(dataType)) + .map((dataType) => ( - - - ))} - {writableDataTypes?.map((dataType) => ( - - - - ))} + ))} + {allDataTypes + ?.filter((dataType) => !schemaDone.includes(dataType)) + .map((dataType) => ( + + ))} + {writableDataTypes + ?.filter((dataType) => !validationConfigDone.includes(dataType)) + .map((dataType) => ( + + ))} ); } @@ -310,12 +325,13 @@ const emptyArray = []; export const DataModels = { useFullStateRef: () => useSelectorAsRef((state) => state), - useLaxDefaultDataType: () => useLaxMemoSelector((state) => state.defaultDataType), + useDefaultDataType: () => useSelector((state) => state.defaultDataType), + useLaxDefaultDataType: () => useLaxSelector((state) => state.defaultDataType), // The following hooks use emptyArray if the value is null, so cannot be used to determine whether or not the datamodels are finished loading - useReadableDataTypes: () => useMemoSelector((state) => state.allDataTypes ?? emptyArray), - useLaxReadableDataTypes: () => useLaxMemoSelector((state) => state.allDataTypes ?? emptyArray), - useWritableDataTypes: () => useMemoSelector((state) => state.writableDataTypes ?? emptyArray), + useReadableDataTypes: () => useSelector((state) => state.allDataTypes ?? emptyArray), + useLaxReadableDataTypes: () => useLaxSelector((state) => state.allDataTypes ?? emptyArray), + useWritableDataTypes: () => useSelector((state) => state.writableDataTypes ?? emptyArray), useDataModelSchema: (dataType: string) => useSelector((state) => state.schemas[dataType]), @@ -333,12 +349,12 @@ export const DataModels = { useSelector((state) => state.expressionValidationConfigs[dataType]), useDefaultDataElementId: () => - useMemoSelector((state) => (state.defaultDataType ? state.dataElementIds[state.defaultDataType] : null)), + useSelector((state) => (state.defaultDataType ? state.dataElementIds[state.defaultDataType] : null)), - useDataElementIdForDataType: (dataType: string) => useMemoSelector((state) => state.dataElementIds[dataType]), + useDataElementIdForDataType: (dataType: string) => useSelector((state) => state.dataElementIds[dataType]), useGetDataElementIdForDataType: () => { - const dataElementIds = useMemoSelector((state) => state.dataElementIds); + const dataElementIds = useSelector((state) => state.dataElementIds); return useCallback((dataType: string) => dataElementIds[dataType], [dataElementIds]); }, diff --git a/src/features/language/useLanguage.ts b/src/features/language/useLanguage.ts index 15f6a2aced..f904d4edf5 100644 --- a/src/features/language/useLanguage.ts +++ b/src/features/language/useLanguage.ts @@ -13,7 +13,11 @@ import { useFormComponentCtx } from 'src/layout/FormComponentContext'; import { getKeyWithoutIndexIndicators } from 'src/utils/databindings'; import { transposeDataBinding } from 'src/utils/databindings/DataBinding'; import { smartLowerCaseFirst } from 'src/utils/formComponentUtils'; -import { useDataModelBindingTranspose } from 'src/utils/layout/useDataModelBindingTranspose'; +import { NodesInternal } from 'src/utils/layout/NodesContext'; +import { + useDataModelBindingTranspose, + useInnerDataModelBindingTranspose, +} from 'src/utils/layout/useDataModelBindingTranspose'; import type { DataModelReader, useDataModelReaders } from 'src/features/formData/FormDataReaders'; import type { LangDataSources, @@ -25,6 +29,7 @@ import type { FormDataSelector } from 'src/layout'; import type { IDataModelReference } from 'src/layout/common.generated'; import type { IApplicationSettings, IInstanceDataSources, ILanguage, IVariable } from 'src/types/shared'; import type { LayoutNode } from 'src/utils/layout/LayoutNode'; +import type { LaxNodeDataSelector } from 'src/utils/layout/NodesContext'; import type { DataModelTransposeSelector } from 'src/utils/layout/useDataModelBindingTranspose'; type SimpleLangParam = string | number | undefined; @@ -127,11 +132,22 @@ export function useLanguageWithForcedNode(node: LayoutNode | undefined) { // Exactly the same as above, but returns a function accepting a node export function useLanguageWithForcedNodeSelector() { - const sources = useLangToolsDataSources(); const defaultDataType = DataModels.useLaxDefaultDataType(); const formDataTypes = DataModels.useLaxReadableDataTypes(); const formDataSelector = FD.useLaxDebouncedSelector(); - const transposeSelector = useDataModelBindingTranspose(); + const nodeDataSelector = NodesInternal.useLaxNodeDataSelector(); + + return useInnerLanguageWithForcedNodeSelector(defaultDataType, formDataTypes, formDataSelector, nodeDataSelector); +} + +export function useInnerLanguageWithForcedNodeSelector( + defaultDataType: string | typeof ContextNotProvided | undefined, + formDataTypes: string[] | typeof ContextNotProvided, + formDataSelector: FormDataSelector | typeof ContextNotProvided, + nodeDataSelector: LaxNodeDataSelector, +) { + const sources = useLangToolsDataSources(); + const transposeSelector = useInnerDataModelBindingTranspose(nodeDataSelector); return useCallback( (node: LayoutNode | undefined) => { diff --git a/src/features/options/useGetOptionsQuery.ts b/src/features/options/useGetOptionsQuery.ts index 19f8c6457f..289b238e4e 100644 --- a/src/features/options/useGetOptionsQuery.ts +++ b/src/features/options/useGetOptionsQuery.ts @@ -8,7 +8,7 @@ import { useLaxInstanceId } from 'src/features/instance/InstanceContext'; import { useCurrentLanguage } from 'src/features/language/LanguageProvider'; import { castOptionsToStrings } from 'src/features/options/castOptionsToStrings'; import { resolveQueryParameters } from 'src/features/options/evalQueryParameters'; -import { useExpressionDataSources } from 'src/utils/layout/useExpressionDataSources'; +import { GeneratorData } from 'src/utils/layout/generator/GeneratorDataSources'; import { getOptionsUrl } from 'src/utils/urls/appUrlHelper'; import type { QueryDefinition } from 'src/core/queries/usePrefetchQuery'; import type { IOptionInternal } from 'src/features/options/castOptionsToStrings'; @@ -45,7 +45,7 @@ export const useGetOptionsUrl = ( const mappingResult = FD.useMapping(mapping); const language = useCurrentLanguage(); const instanceId = useLaxInstanceId(); - const dataSources = useExpressionDataSources(); + const dataSources = GeneratorData.useExpressionDataSources(); const resolvedQueryParameters = resolveQueryParameters(queryParameters, node, dataSources); return optionsId diff --git a/src/hooks/useSourceOptions.ts b/src/hooks/useSourceOptions.ts index 3ad468ba35..7feae6c21b 100644 --- a/src/hooks/useSourceOptions.ts +++ b/src/hooks/useSourceOptions.ts @@ -3,7 +3,7 @@ import { ExprValidation } from 'src/features/expressions/validation'; import { useMemoDeepEqual } from 'src/hooks/useStateDeepEqual'; import { getKeyWithoutIndexIndicators } from 'src/utils/databindings'; import { transposeDataBinding } from 'src/utils/databindings/DataBinding'; -import { useExpressionDataSources } from 'src/utils/layout/useExpressionDataSources'; +import { GeneratorData } from 'src/utils/layout/generator/GeneratorDataSources'; import type { ExprVal, ExprValToActualOrExpr } from 'src/features/expressions/types'; import type { IOptionInternal } from 'src/features/options/castOptionsToStrings'; import type { IDataModelReference, IOptionSource } from 'src/layout/common.generated'; @@ -16,7 +16,7 @@ interface IUseSourceOptionsArgs { } export const useSourceOptions = ({ source, node }: IUseSourceOptionsArgs): IOptionInternal[] | undefined => { - const dataSources = useExpressionDataSources(); + const dataSources = GeneratorData.useExpressionDataSources(); return useMemoDeepEqual(() => { if (!source) { diff --git a/src/utils/layout/NodesContext.tsx b/src/utils/layout/NodesContext.tsx index cd0fb2540f..0d2d5d40e9 100644 --- a/src/utils/layout/NodesContext.tsx +++ b/src/utils/layout/NodesContext.tsx @@ -36,6 +36,7 @@ import { getComponentDef } from 'src/layout'; import { useGetAwaitingCommits } from 'src/utils/layout/generator/CommitQueue'; import { GeneratorDebug, generatorLog } from 'src/utils/layout/generator/debug'; import { GeneratorGlobalProvider, GeneratorInternal } from 'src/utils/layout/generator/GeneratorContext'; +import { GeneratorData } from 'src/utils/layout/generator/GeneratorDataSources'; import { createStagesStore, GeneratorStages, @@ -487,7 +488,9 @@ export const NodesProvider = ({ children }: React.PropsWithChildren) => { - + + + {window.Cypress && } @@ -947,6 +950,9 @@ export const Hidden = { }, useIsHiddenSelector() { const forcedVisibleByDevTools = Hidden.useIsForcedVisibleByDevTools(); + return Hidden.useInnerIsHiddenSelector(forcedVisibleByDevTools); + }, + useInnerIsHiddenSelector(forcedVisibleByDevTools: boolean) { return Store.useDelayedSelector({ mode: 'simple', selector: (node: LayoutNode | LayoutPage, options?: IsHiddenOptions) => (state) => diff --git a/src/utils/layout/generator/GeneratorDataSources.tsx b/src/utils/layout/generator/GeneratorDataSources.tsx new file mode 100644 index 0000000000..81df14d784 --- /dev/null +++ b/src/utils/layout/generator/GeneratorDataSources.tsx @@ -0,0 +1,104 @@ +import { useMemo } from 'react'; + +import { createHookContext } from 'src/core/contexts/hookContext'; +import { useApplicationMetadata } from 'src/features/applicationMetadata/ApplicationMetadataProvider'; +import { useApplicationSettings } from 'src/features/applicationSettings/ApplicationSettingsProvider'; +import { useAttachmentsSelector } from 'src/features/attachments/hooks'; +import { DataModels } from 'src/features/datamodel/DataModelsProvider'; +import { useExternalApis } from 'src/features/externalApi/useExternalApi'; +import { useCurrentLayoutSet } from 'src/features/form/layoutSets/useCurrentLayoutSet'; +import { FD } from 'src/features/formData/FormDataWrite'; +import { useLaxInstanceDataSources } from 'src/features/instance/InstanceContext'; +import { useLaxProcessData } from 'src/features/instance/ProcessContext'; +import { useCurrentLanguage } from 'src/features/language/LanguageProvider'; +import { useInnerLanguageWithForcedNodeSelector } from 'src/features/language/useLanguage'; +import { useNodeOptionsSelector } from 'src/features/options/useNodeOptions'; +import { Hidden, NodesInternal, useNodes } from 'src/utils/layout/NodesContext'; +import { useInnerDataModelBindingTranspose } from 'src/utils/layout/useDataModelBindingTranspose'; +import { useInnerNodeFormDataSelector } from 'src/utils/layout/useNodeItem'; +import { useInnerNodeTraversalSelector } from 'src/utils/layout/useNodeTraversal'; +import type { ExpressionDataSources } from 'src/utils/layout/useExpressionDataSources'; + +const { Provider, hooks } = createHookContext({ + useLaxInstanceDataSources: () => useLaxInstanceDataSources(), + useCurrentLayoutSet: () => useCurrentLayoutSet(), + useDefaultDataType: () => DataModels.useDefaultDataType(), + useReadableDataTypes: () => DataModels.useReadableDataTypes(), + useExternalApis: () => useExternalApis(useApplicationMetadata().externalApiIds ?? []), + useNodes: () => useNodes(), + useIsForcedVisibleByDevTools: () => Hidden.useIsForcedVisibleByDevTools(), +}); + +export const GeneratorData = { + Provider, + useExpressionDataSources, +}; + +function useExpressionDataSources(): ExpressionDataSources { + const formDataSelector = FD.useDebouncedSelector(); + const formDataRowsSelector = FD.useDebouncedRowsSelector(); + const attachmentsSelector = useAttachmentsSelector(); + const optionsSelector = useNodeOptionsSelector(); + const nodeDataSelector = NodesInternal.useNodeDataSelector(); + + const process = useLaxProcessData(); + const applicationSettings = useApplicationSettings(); + const currentLanguage = useCurrentLanguage(); + + const instanceDataSources = hooks.useLaxInstanceDataSources(); + const currentLayoutSet = hooks.useCurrentLayoutSet() ?? null; + const dataModelNames = hooks.useReadableDataTypes(); + const externalApis = hooks.useExternalApis(); + + const isHiddenSelector = Hidden.useInnerIsHiddenSelector(hooks.useIsForcedVisibleByDevTools()); + const nodeTraversal = useInnerNodeTraversalSelector(hooks.useNodes()); + const transposeSelector = useInnerDataModelBindingTranspose(nodeDataSelector); + const nodeFormDataSelector = useInnerNodeFormDataSelector(nodeDataSelector, formDataSelector); + const langToolsSelector = useInnerLanguageWithForcedNodeSelector( + hooks.useDefaultDataType(), + dataModelNames, + formDataSelector, + nodeDataSelector, + ); + + return useMemo( + () => ({ + formDataSelector, + formDataRowsSelector, + attachmentsSelector, + process, + optionsSelector, + applicationSettings, + instanceDataSources, + langToolsSelector, + currentLanguage, + isHiddenSelector, + nodeFormDataSelector, + nodeDataSelector, + nodeTraversal, + transposeSelector, + currentLayoutSet, + externalApis, + dataModelNames, + }), + [ + formDataSelector, + formDataRowsSelector, + attachmentsSelector, + process, + optionsSelector, + applicationSettings, + instanceDataSources, + langToolsSelector, + currentLanguage, + isHiddenSelector, + nodeFormDataSelector, + nodeDataSelector, + nodeTraversal, + transposeSelector, + currentLayoutSet, + externalApis, + dataModelNames, + ], + ); +} diff --git a/src/utils/layout/generator/NodeGenerator.tsx b/src/utils/layout/generator/NodeGenerator.tsx index 747f580f8a..679c5e4163 100644 --- a/src/utils/layout/generator/NodeGenerator.tsx +++ b/src/utils/layout/generator/NodeGenerator.tsx @@ -9,6 +9,7 @@ import { getComponentDef, getNodeConstructor } from 'src/layout'; import { NodesStateQueue } from 'src/utils/layout/generator/CommitQueue'; import { GeneratorDebug } from 'src/utils/layout/generator/debug'; import { GeneratorInternal, GeneratorNodeProvider } from 'src/utils/layout/generator/GeneratorContext'; +import { GeneratorData } from 'src/utils/layout/generator/GeneratorDataSources'; import { useGeneratorErrorBoundaryNodeRef } from 'src/utils/layout/generator/GeneratorErrorBoundary'; import { GeneratorCondition, @@ -20,7 +21,6 @@ import { import { useEvalExpressionInGenerator } from 'src/utils/layout/generator/useEvalExpression'; import { NodePropertiesValidation } from 'src/utils/layout/generator/validation/NodePropertiesValidation'; import { NodesInternal } from 'src/utils/layout/NodesContext'; -import { useExpressionDataSources } from 'src/utils/layout/useExpressionDataSources'; import type { SimpleEval } from 'src/features/expressions'; import type { ExprConfig, ExprResolved, ExprValToActual, ExprValToActualOrExpr } from 'src/features/expressions/types'; import type { CompDef } from 'src/layout'; @@ -171,7 +171,7 @@ export function useExpressionResolverProps( _item: CompIntermediateExact, rowIndex?: number, ): ExprResolver { - const allDataSources = useExpressionDataSources(); + const allDataSources = GeneratorData.useExpressionDataSources(); const allDataSourcesAsRef = useAsRef(allDataSources); // The hidden property is handled elsewhere, and should never be passed to the item (and resolved as an diff --git a/src/utils/layout/generator/debug.ts b/src/utils/layout/generator/debug.ts index 115f06b783..b57e234a53 100644 --- a/src/utils/layout/generator/debug.ts +++ b/src/utils/layout/generator/debug.ts @@ -3,8 +3,8 @@ export const GeneratorDebug = { displayState: debugAll, displayReadiness: debugAll, logReadiness: debugAll, - logStages: debugAll, - logCommits: debugAll, + logStages: debugAll || true, + logCommits: debugAll || true, }; export const generatorLog = (logType: keyof typeof GeneratorDebug, ...messages: unknown[]) => { diff --git a/src/utils/layout/generator/useEvalExpression.ts b/src/utils/layout/generator/useEvalExpression.ts index 9f5a38df91..73b18d9b36 100644 --- a/src/utils/layout/generator/useEvalExpression.ts +++ b/src/utils/layout/generator/useEvalExpression.ts @@ -2,9 +2,9 @@ import { useMemo } from 'react'; import { evalExpr } from 'src/features/expressions'; import { ExprValidation } from 'src/features/expressions/validation'; +import { GeneratorData } from 'src/utils/layout/generator/GeneratorDataSources'; import { GeneratorStages } from 'src/utils/layout/generator/GeneratorStages'; import { LayoutPage } from 'src/utils/layout/LayoutPage'; -import { useExpressionDataSources } from 'src/utils/layout/useExpressionDataSources'; import type { ExprConfig, ExprVal, ExprValToActual, ExprValToActualOrExpr } from 'src/features/expressions/types'; import type { LayoutNode } from 'src/utils/layout/LayoutNode'; @@ -43,7 +43,7 @@ export function useEvalExpression( defaultValue: ExprValToActual, enabled = true, ) { - const allDataSources = useExpressionDataSources(); + const allDataSources = GeneratorData.useExpressionDataSources(); return useMemo(() => { if (!enabled) { diff --git a/src/utils/layout/useDataModelBindingTranspose.ts b/src/utils/layout/useDataModelBindingTranspose.ts index 845d245249..04ca673b63 100644 --- a/src/utils/layout/useDataModelBindingTranspose.ts +++ b/src/utils/layout/useDataModelBindingTranspose.ts @@ -27,7 +27,9 @@ export type DataModelTransposeSelector = ReturnType { const { currentLocation, currentLocationIsRepGroup, foundRowIndex } = firstDataModelBinding(node, nodeSelector); diff --git a/src/utils/layout/useNodeItem.ts b/src/utils/layout/useNodeItem.ts index acd86a249d..2d9429b2d1 100644 --- a/src/utils/layout/useNodeItem.ts +++ b/src/utils/layout/useNodeItem.ts @@ -9,6 +9,7 @@ import type { FormDataSelector } from 'src/layout'; import type { CompInternal, CompTypes, IDataModelBindings, TypeFromNode } from 'src/layout/layout'; import type { IComponentFormData } from 'src/utils/formComponentUtils'; import type { LayoutNode } from 'src/utils/layout/LayoutNode'; +import type { NodeDataSelector } from 'src/utils/layout/NodesContext'; import type { NodeData, NodeItemFromNode } from 'src/utils/layout/types'; import type { TraversalRestriction } from 'src/utils/layout/useNodeTraversal'; @@ -146,6 +147,9 @@ export function useNodeFormDataSelector() { const nodeSelector = NodesInternal.useNodeDataSelector(); const formDataSelector = FD.useDebouncedSelector(); + return useInnerNodeFormDataSelector(nodeSelector, formDataSelector); +} +export function useInnerNodeFormDataSelector(nodeSelector: NodeDataSelector, formDataSelector: FormDataSelector) { return useCallback( (node: N): NodeFormData => { const dataModelBindings = nodeSelector((picker) => picker(node)?.layout.dataModelBindings, [node]); diff --git a/src/utils/layout/useNodeTraversal.ts b/src/utils/layout/useNodeTraversal.ts index c5da46a07c..27a6847268 100644 --- a/src/utils/layout/useNodeTraversal.ts +++ b/src/utils/layout/useNodeTraversal.ts @@ -310,6 +310,13 @@ function throwOrReturn(value: R, strictness: Strictness) { */ function useNodeTraversalSelectorProto(strictness: Strict) { const nodes = useNodesLax(); + return useInnerNodeTraversalSelectorProto(strictness, nodes); +} + +function useInnerNodeTraversalSelectorProto( + strictness: Strict, + nodes: ReturnType, +) { const selectState = NodesInternal.useDataSelectorForTraversal(); return useCallback( @@ -336,4 +343,8 @@ export function useNodeTraversalSelector() { return useNodeTraversalSelectorProto(Strictness.throwError); } +export function useInnerNodeTraversalSelector(nodes: ReturnType) { + return useInnerNodeTraversalSelectorProto(Strictness.throwError, nodes); +} + export type NodeTraversalSelector = ReturnType; From d97e43997f1ad6c35ef9ca610d774504305e3b1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rnar=20=C3=98sttveit?= Date: Thu, 7 Nov 2024 18:31:01 +0100 Subject: [PATCH 02/25] add shallow object memo --- src/hooks/useShallowObjectMemo.ts | 24 +++++++ .../layout/generator/GeneratorDataSources.tsx | 68 ++++++------------- 2 files changed, 46 insertions(+), 46 deletions(-) create mode 100644 src/hooks/useShallowObjectMemo.ts diff --git a/src/hooks/useShallowObjectMemo.ts b/src/hooks/useShallowObjectMemo.ts new file mode 100644 index 0000000000..965221000d --- /dev/null +++ b/src/hooks/useShallowObjectMemo.ts @@ -0,0 +1,24 @@ +import { useRef } from 'react'; + +/** + * Similar to useShallow from zustand: https://zustand.docs.pmnd.rs/guides/prevent-rerenders-with-use-shallow + * only this works on objects directly instead of selectors. + */ +export function useShallowObjectMemo(next: T): T { + const prev = useRef(); + return objectShallowEqual(next, prev.current) ? prev.current : (prev.current = next); +} + +type Object = { [key: string]: unknown }; + +function objectShallowEqual(next: T, prev?: T): prev is T { + if (!prev) { + return false; + } + for (const key in next) { + if (next[key] !== prev[key]) { + return false; + } + } + return true; +} diff --git a/src/utils/layout/generator/GeneratorDataSources.tsx b/src/utils/layout/generator/GeneratorDataSources.tsx index 81df14d784..ad91ca04c3 100644 --- a/src/utils/layout/generator/GeneratorDataSources.tsx +++ b/src/utils/layout/generator/GeneratorDataSources.tsx @@ -1,9 +1,6 @@ -import { useMemo } from 'react'; - import { createHookContext } from 'src/core/contexts/hookContext'; import { useApplicationMetadata } from 'src/features/applicationMetadata/ApplicationMetadataProvider'; import { useApplicationSettings } from 'src/features/applicationSettings/ApplicationSettingsProvider'; -import { useAttachmentsSelector } from 'src/features/attachments/hooks'; import { DataModels } from 'src/features/datamodel/DataModelsProvider'; import { useExternalApis } from 'src/features/externalApi/useExternalApi'; import { useCurrentLayoutSet } from 'src/features/form/layoutSets/useCurrentLayoutSet'; @@ -12,7 +9,7 @@ import { useLaxInstanceDataSources } from 'src/features/instance/InstanceContext import { useLaxProcessData } from 'src/features/instance/ProcessContext'; import { useCurrentLanguage } from 'src/features/language/LanguageProvider'; import { useInnerLanguageWithForcedNodeSelector } from 'src/features/language/useLanguage'; -import { useNodeOptionsSelector } from 'src/features/options/useNodeOptions'; +import { useShallowObjectMemo } from 'src/hooks/useShallowObjectMemo'; import { Hidden, NodesInternal, useNodes } from 'src/utils/layout/NodesContext'; import { useInnerDataModelBindingTranspose } from 'src/utils/layout/useDataModelBindingTranspose'; import { useInnerNodeFormDataSelector } from 'src/utils/layout/useNodeItem'; @@ -37,8 +34,8 @@ export const GeneratorData = { function useExpressionDataSources(): ExpressionDataSources { const formDataSelector = FD.useDebouncedSelector(); const formDataRowsSelector = FD.useDebouncedRowsSelector(); - const attachmentsSelector = useAttachmentsSelector(); - const optionsSelector = useNodeOptionsSelector(); + const attachmentsSelector = NodesInternal.useAttachmentsSelector(); + const optionsSelector = NodesInternal.useNodeOptionsSelector(); const nodeDataSelector = NodesInternal.useNodeDataSelector(); const process = useLaxProcessData(); @@ -61,44 +58,23 @@ function useExpressionDataSources(): ExpressionDataSources { nodeDataSelector, ); - return useMemo( - () => ({ - formDataSelector, - formDataRowsSelector, - attachmentsSelector, - process, - optionsSelector, - applicationSettings, - instanceDataSources, - langToolsSelector, - currentLanguage, - isHiddenSelector, - nodeFormDataSelector, - nodeDataSelector, - nodeTraversal, - transposeSelector, - currentLayoutSet, - externalApis, - dataModelNames, - }), - [ - formDataSelector, - formDataRowsSelector, - attachmentsSelector, - process, - optionsSelector, - applicationSettings, - instanceDataSources, - langToolsSelector, - currentLanguage, - isHiddenSelector, - nodeFormDataSelector, - nodeDataSelector, - nodeTraversal, - transposeSelector, - currentLayoutSet, - externalApis, - dataModelNames, - ], - ); + return useShallowObjectMemo({ + formDataSelector, + formDataRowsSelector, + attachmentsSelector, + process, + optionsSelector, + applicationSettings, + instanceDataSources, + langToolsSelector, + currentLanguage, + isHiddenSelector, + nodeFormDataSelector, + nodeDataSelector, + nodeTraversal, + transposeSelector, + currentLayoutSet, + externalApis, + dataModelNames, + }); } From e9132ac94a95e5d849ff7dd52a2f843b02aa66b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rnar=20=C3=98sttveit?= Date: Fri, 8 Nov 2024 10:34:25 +0100 Subject: [PATCH 03/25] some more incremental improvements --- src/features/dataLists/useDataListQuery.tsx | 3 ++- src/features/formData/FormDataWrite.tsx | 15 ++++++------ src/features/options/useGetOptionsQuery.ts | 2 +- .../nodeValidation/useNodeValidation.ts | 7 +++--- .../InstantiationButton.tsx | 3 ++- .../PaymentDetailsComponent.tsx | 3 ++- src/utils/layout/NodesContext.tsx | 20 ++++------------ src/utils/layout/generator/CommitQueue.tsx | 5 ++-- .../layout/generator/GeneratorDataSources.tsx | 23 +++++++++++++++++-- .../layout/generator/GeneratorStages.tsx | 4 +--- src/utils/layout/generator/debug.ts | 4 ++-- 11 files changed, 50 insertions(+), 39 deletions(-) diff --git a/src/features/dataLists/useDataListQuery.tsx b/src/features/dataLists/useDataListQuery.tsx index 032adbbafe..e7635734ad 100644 --- a/src/features/dataLists/useDataListQuery.tsx +++ b/src/features/dataLists/useDataListQuery.tsx @@ -5,6 +5,7 @@ import type { SortDirection } from '@digdir/design-system-react/dist/types/compo import type { UseQueryResult } from '@tanstack/react-query'; import { useAppQueries } from 'src/core/contexts/AppQueriesProvider'; +import { DataModels } from 'src/features/datamodel/DataModelsProvider'; import { FD } from 'src/features/formData/FormDataWrite'; import { useLaxInstanceId } from 'src/features/instance/InstanceContext'; import { useCurrentLanguage } from 'src/features/language/LanguageProvider'; @@ -29,7 +30,7 @@ export const useDataListQuery = ( const { fetchDataList } = useAppQueries(); const selectedLanguage = useCurrentLanguage(); const instanceId = useLaxInstanceId(); - const mappingResult = FD.useMapping(mapping); + const mappingResult = FD.useMapping(mapping, DataModels.useDefaultDataType()); const { pageSize, pageNumber, sortColumn, sortDirection } = filter || {}; const url = getDataListsUrl({ diff --git a/src/features/formData/FormDataWrite.tsx b/src/features/formData/FormDataWrite.tsx index d4699d2042..23a920c0e2 100644 --- a/src/features/formData/FormDataWrite.tsx +++ b/src/features/formData/FormDataWrite.tsx @@ -10,7 +10,7 @@ import { ContextNotProvided } from 'src/core/contexts/context'; import { createZustandContext } from 'src/core/contexts/zustandContext'; import { useApplicationMetadata } from 'src/features/applicationMetadata/ApplicationMetadataProvider'; import { DataModels } from 'src/features/datamodel/DataModelsProvider'; -import { useCurrentDataModelName, useGetDataModelUrl } from 'src/features/datamodel/useBindingSchema'; +import { useGetDataModelUrl } from 'src/features/datamodel/useBindingSchema'; import { useRuleConnections } from 'src/features/form/dynamics/DynamicsContext'; import { usePageSettings } from 'src/features/form/layoutSettings/LayoutSettingsContext'; import { useFormDataWriteProxies } from 'src/features/formData/FormDataWriteProxies'; @@ -705,17 +705,17 @@ export const FD = { */ useMapping: ( mapping: IMapping | undefined, + defaultDataType: string | undefined, dataAs?: D, - ): D extends 'raw' ? { [key: string]: FDValue } : { [key: string]: string } => { - const currentDataType = useCurrentDataModelName(); - return useMemoSelector((s) => { + ): D extends 'raw' ? { [key: string]: FDValue } : { [key: string]: string } => + useMemoSelector((s) => { const realDataAs = dataAs || 'string'; // eslint-disable-next-line @typescript-eslint/no-explicit-any const out: any = {}; - if (mapping && currentDataType) { + if (mapping && defaultDataType) { for (const key of Object.keys(mapping)) { const outputKey = mapping[key]; - const value = dot.pick(key, s.dataModels[currentDataType].debouncedCurrentData); + const value = dot.pick(key, s.dataModels[defaultDataType].debouncedCurrentData); if (realDataAs === 'raw') { out[outputKey] = value; @@ -729,8 +729,7 @@ export const FD = { } } return out; - }); - }, + }), /** * This returns the raw method for setting a value in the form data. This is useful if you want to diff --git a/src/features/options/useGetOptionsQuery.ts b/src/features/options/useGetOptionsQuery.ts index 289b238e4e..ea70edc46a 100644 --- a/src/features/options/useGetOptionsQuery.ts +++ b/src/features/options/useGetOptionsQuery.ts @@ -42,7 +42,7 @@ export const useGetOptionsUrl = ( queryParameters?: IQueryParameters, secure?: boolean, ): string | undefined => { - const mappingResult = FD.useMapping(mapping); + const mappingResult = FD.useMapping(mapping, GeneratorData.useDefaultDataType()); const language = useCurrentLanguage(); const instanceId = useLaxInstanceId(); const dataSources = GeneratorData.useExpressionDataSources(); diff --git a/src/features/validation/nodeValidation/useNodeValidation.ts b/src/features/validation/nodeValidation/useNodeValidation.ts index f9d7af244d..19ef9ced78 100644 --- a/src/features/validation/nodeValidation/useNodeValidation.ts +++ b/src/features/validation/nodeValidation/useNodeValidation.ts @@ -2,13 +2,13 @@ import { useMemo } from 'react'; import { useApplicationMetadata } from 'src/features/applicationMetadata/ApplicationMetadataProvider'; import { useAttachmentsSelector } from 'src/features/attachments/hooks'; -import { DataModels } from 'src/features/datamodel/DataModelsProvider'; import { useLayoutSets } from 'src/features/form/layoutSets/LayoutSetsProvider'; import { FD } from 'src/features/formData/FormDataWrite'; import { useLaxDataElementsSelector } from 'src/features/instance/InstanceContext'; import { useCurrentLanguage } from 'src/features/language/LanguageProvider'; import { Validation } from 'src/features/validation/validationContext'; import { implementsValidateComponent, implementsValidateEmptyField } from 'src/layout'; +import { GeneratorData } from 'src/utils/layout/generator/GeneratorDataSources'; import { NodesInternal } from 'src/utils/layout/NodesContext'; import type { AnyValidation, @@ -32,8 +32,9 @@ export function useNodeValidation( const dataModelSelector = Validation.useDataModelSelector(); const validationDataSources = useValidationDataSources(); const nodeDataSelector = NodesInternal.useNodeDataSelector(); - const getDataElementIdForDataType = DataModels.useGetDataElementIdForDataType(); - const processedLast = Validation.useProcessedLast(); + + const getDataElementIdForDataType = GeneratorData.useGetDataElementIdForDataType(); + const processedLast = GeneratorData.useValidationsProcessedLast(); return { processedLast, diff --git a/src/layout/InstantiationButton/InstantiationButton.tsx b/src/layout/InstantiationButton/InstantiationButton.tsx index 6dcc65900c..8b71608ea1 100644 --- a/src/layout/InstantiationButton/InstantiationButton.tsx +++ b/src/layout/InstantiationButton/InstantiationButton.tsx @@ -1,5 +1,6 @@ import React from 'react'; +import { DataModels } from 'src/features/datamodel/DataModelsProvider'; import { FD } from 'src/features/formData/FormDataWrite'; import { useInstantiation } from 'src/features/instantiate/InstantiationContext'; import { useCurrentParty } from 'src/features/party/PartiesProvider'; @@ -11,7 +12,7 @@ type Props = Omit { const { instantiateWithPrefill, error, isLoading } = useInstantiation(); - const prefill = FD.useMapping(props.mapping); + const prefill = FD.useMapping(props.mapping, DataModels.useDefaultDataType()); const party = useCurrentParty(); const instantiate = () => { diff --git a/src/layout/PaymentDetails/PaymentDetailsComponent.tsx b/src/layout/PaymentDetails/PaymentDetailsComponent.tsx index 130622217a..98df947e01 100644 --- a/src/layout/PaymentDetails/PaymentDetailsComponent.tsx +++ b/src/layout/PaymentDetails/PaymentDetailsComponent.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useRef } from 'react'; import deepEqual from 'fast-deep-equal'; +import { DataModels } from 'src/features/datamodel/DataModelsProvider'; import { FD } from 'src/features/formData/FormDataWrite'; import { useOrderDetails, useRefetchOrderDetails } from 'src/features/payment/OrderDetailsProvider'; import { ComponentStructureWrapper } from 'src/layout/ComponentStructureWrapper'; @@ -18,7 +19,7 @@ export function PaymentDetailsComponent({ node }: IPaymentDetailsProps) { const mapping = useNodeItem(node, (i) => i.mapping); const hasUnsavedChanges = FD.useHasUnsavedChanges(); - const mappedValues = FD.useMapping(mapping); + const mappedValues = FD.useMapping(mapping, DataModels.useDefaultDataType()); const prevMappedValues = useRef | undefined>(undefined); // refetch data if we have configured mapping and the mapped values have changed diff --git a/src/utils/layout/NodesContext.tsx b/src/utils/layout/NodesContext.tsx index 0d2d5d40e9..3bc8dc3d47 100644 --- a/src/utils/layout/NodesContext.tsx +++ b/src/utils/layout/NodesContext.tsx @@ -14,7 +14,6 @@ import { createZustandContext } from 'src/core/contexts/zustandContext'; import { Loader } from 'src/core/loading/Loader'; import { AttachmentsStorePlugin } from 'src/features/attachments/AttachmentsStorePlugin'; import { UpdateAttachmentsForCypress } from 'src/features/attachments/UpdateAttachmentsForCypress'; -import { useDevToolsStore } from 'src/features/devtools/data/DevToolsStore'; import { HiddenComponentsProvider } from 'src/features/form/dynamics/HiddenComponentsProvider'; import { useLayouts } from 'src/features/form/layout/LayoutsContext'; import { useLaxLayoutSettings, useLayoutSettings } from 'src/features/form/layoutSettings/LayoutSettingsContext'; @@ -926,15 +925,15 @@ function makeOptions(forcedVisibleByDevTools: boolean, options?: AccessibleIsHid export type IsHiddenSelector = ReturnType; export const Hidden = { useIsHidden(node: LayoutNode | LayoutPage | undefined, options?: AccessibleIsHiddenOptions) { - const forcedVisibleByDevTools = Hidden.useIsForcedVisibleByDevTools(); + const forcedVisibleByDevTools = GeneratorData.useIsForcedVisibleByDevTools(); return WhenReady.useMemoSelector((s) => isHidden(s, node, makeOptions(forcedVisibleByDevTools, options))); }, useIsHiddenPage(page: LayoutPage | string | undefined, options?: AccessibleIsHiddenOptions) { - const forcedVisibleByDevTools = Hidden.useIsForcedVisibleByDevTools(); + const forcedVisibleByDevTools = GeneratorData.useIsForcedVisibleByDevTools(); return WhenReady.useMemoSelector((s) => isHiddenPage(s, page, makeOptions(forcedVisibleByDevTools, options))); }, useIsHiddenPageSelector() { - const forcedVisibleByDevTools = Hidden.useIsForcedVisibleByDevTools(); + const forcedVisibleByDevTools = GeneratorData.useIsForcedVisibleByDevTools(); return Store.useDelayedSelector({ mode: 'simple', selector: (page: LayoutPage | string) => (state) => @@ -942,17 +941,14 @@ export const Hidden = { }); }, useHiddenPages(): Set { - const forcedVisibleByDevTools = Hidden.useIsForcedVisibleByDevTools(); + const forcedVisibleByDevTools = GeneratorData.useIsForcedVisibleByDevTools(); const hiddenPages = WhenReady.useLaxMemoSelector((s) => Object.keys(s.pagesData.pages).filter((key) => isHiddenPage(s, key, makeOptions(forcedVisibleByDevTools))), ); return useMemo(() => new Set(hiddenPages === ContextNotProvided ? [] : hiddenPages), [hiddenPages]); }, useIsHiddenSelector() { - const forcedVisibleByDevTools = Hidden.useIsForcedVisibleByDevTools(); - return Hidden.useInnerIsHiddenSelector(forcedVisibleByDevTools); - }, - useInnerIsHiddenSelector(forcedVisibleByDevTools: boolean) { + const forcedVisibleByDevTools = GeneratorData.useIsForcedVisibleByDevTools(); return Store.useDelayedSelector({ mode: 'simple', selector: (node: LayoutNode | LayoutPage, options?: IsHiddenOptions) => (state) => @@ -963,12 +959,6 @@ export const Hidden = { /** * The next ones are primarily for internal use: */ - useIsForcedVisibleByDevTools() { - const devToolsIsOpen = useDevToolsStore((state) => state.isOpen); - const devToolsHiddenComponents = useDevToolsStore((state) => state.hiddenComponents); - - return devToolsIsOpen && devToolsHiddenComponents !== 'hide'; - }, useIsPageInOrder(pageKey: string) { const currentView = useCurrentView(); const maybeLayoutSettings = useLaxLayoutSettings(); diff --git a/src/utils/layout/generator/CommitQueue.tsx b/src/utils/layout/generator/CommitQueue.tsx index 385a0270fd..31efa9a947 100644 --- a/src/utils/layout/generator/CommitQueue.tsx +++ b/src/utils/layout/generator/CommitQueue.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect } from 'react'; import { generatorLog } from 'src/utils/layout/generator/debug'; import { GeneratorInternal } from 'src/utils/layout/generator/GeneratorContext'; +import { GeneratorData } from 'src/utils/layout/generator/GeneratorDataSources'; import { NODES_TICK_TIMEOUT, StageFinished } from 'src/utils/layout/generator/GeneratorStages'; import { type AddNodeRequest, @@ -152,7 +153,7 @@ function useAddToQueue( ) { const registry = GeneratorInternal.useRegistry(); const toCommit = registry.current.toCommit; - const commit = useCommitWhenFinished(); + const commit = GeneratorData.useCommitWhenFinished(); if (condition) { registry.current.toCommitCount += 1; @@ -172,7 +173,7 @@ function useAddToQueue( * up (setTimeout is slow, at least when debugging), we'll set a timeout once if this selector find out the generator * has finished. */ -function useCommitWhenFinished() { +export function useCommitWhenFinished() { const commit = useCommit(); const registry = GeneratorInternal.useRegistry(); const stateRef = NodesStore.useSelectorAsRef((s) => s.stages); diff --git a/src/utils/layout/generator/GeneratorDataSources.tsx b/src/utils/layout/generator/GeneratorDataSources.tsx index ad91ca04c3..7f7355b9c4 100644 --- a/src/utils/layout/generator/GeneratorDataSources.tsx +++ b/src/utils/layout/generator/GeneratorDataSources.tsx @@ -2,6 +2,7 @@ import { createHookContext } from 'src/core/contexts/hookContext'; import { useApplicationMetadata } from 'src/features/applicationMetadata/ApplicationMetadataProvider'; import { useApplicationSettings } from 'src/features/applicationSettings/ApplicationSettingsProvider'; import { DataModels } from 'src/features/datamodel/DataModelsProvider'; +import { useDevToolsStore } from 'src/features/devtools/data/DevToolsStore'; import { useExternalApis } from 'src/features/externalApi/useExternalApi'; import { useCurrentLayoutSet } from 'src/features/form/layoutSets/useCurrentLayoutSet'; import { FD } from 'src/features/formData/FormDataWrite'; @@ -9,7 +10,9 @@ import { useLaxInstanceDataSources } from 'src/features/instance/InstanceContext import { useLaxProcessData } from 'src/features/instance/ProcessContext'; import { useCurrentLanguage } from 'src/features/language/LanguageProvider'; import { useInnerLanguageWithForcedNodeSelector } from 'src/features/language/useLanguage'; +import { Validation } from 'src/features/validation/validationContext'; import { useShallowObjectMemo } from 'src/hooks/useShallowObjectMemo'; +import { useCommitWhenFinished } from 'src/utils/layout/generator/CommitQueue'; import { Hidden, NodesInternal, useNodes } from 'src/utils/layout/NodesContext'; import { useInnerDataModelBindingTranspose } from 'src/utils/layout/useDataModelBindingTranspose'; import { useInnerNodeFormDataSelector } from 'src/utils/layout/useNodeItem'; @@ -23,12 +26,28 @@ const { Provider, hooks } = createHookContext({ useReadableDataTypes: () => DataModels.useReadableDataTypes(), useExternalApis: () => useExternalApis(useApplicationMetadata().externalApiIds ?? []), useNodes: () => useNodes(), - useIsForcedVisibleByDevTools: () => Hidden.useIsForcedVisibleByDevTools(), + useIsForcedVisibleByDevTools: () => { + const devToolsIsOpen = useDevToolsStore((state) => state.isOpen); + const devToolsHiddenComponents = useDevToolsStore((state) => state.hiddenComponents); + return devToolsIsOpen && devToolsHiddenComponents !== 'hide'; + }, + + useGetDataElementIdForDataType: () => DataModels.useGetDataElementIdForDataType(), + useValidationsProcessedLast: () => Validation.useProcessedLast(), + + useCommitWhenFinished: () => useCommitWhenFinished(), }); export const GeneratorData = { Provider, useExpressionDataSources, + useDefaultDataType: hooks.useDefaultDataType, + useIsForcedVisibleByDevTools: hooks.useIsForcedVisibleByDevTools, + + useGetDataElementIdForDataType: hooks.useGetDataElementIdForDataType, + useValidationsProcessedLast: hooks.useValidationsProcessedLast, + + useCommitWhenFinished: hooks.useCommitWhenFinished, }; function useExpressionDataSources(): ExpressionDataSources { @@ -47,7 +66,7 @@ function useExpressionDataSources(): ExpressionDataSources { const dataModelNames = hooks.useReadableDataTypes(); const externalApis = hooks.useExternalApis(); - const isHiddenSelector = Hidden.useInnerIsHiddenSelector(hooks.useIsForcedVisibleByDevTools()); + const isHiddenSelector = Hidden.useIsHiddenSelector(); const nodeTraversal = useInnerNodeTraversalSelector(hooks.useNodes()); const transposeSelector = useInnerDataModelBindingTranspose(nodeDataSelector); const nodeFormDataSelector = useInnerNodeFormDataSelector(nodeDataSelector, formDataSelector); diff --git a/src/utils/layout/generator/GeneratorStages.tsx b/src/utils/layout/generator/GeneratorStages.tsx index 5595ada125..de170c7a17 100644 --- a/src/utils/layout/generator/GeneratorStages.tsx +++ b/src/utils/layout/generator/GeneratorStages.tsx @@ -397,9 +397,7 @@ export const GeneratorStages = { * finished). */ function useInitialRunNum() { - const runNumberRef = NodesStore.useSelectorAsRef((state) => state.stages.runNum); - - const ref = useRef(runNumberRef.current); + const ref = useRef(NodesStore.useStaticSelector((state) => state.stages.runNum)); return ref.current; } diff --git a/src/utils/layout/generator/debug.ts b/src/utils/layout/generator/debug.ts index b57e234a53..115f06b783 100644 --- a/src/utils/layout/generator/debug.ts +++ b/src/utils/layout/generator/debug.ts @@ -3,8 +3,8 @@ export const GeneratorDebug = { displayState: debugAll, displayReadiness: debugAll, logReadiness: debugAll, - logStages: debugAll || true, - logCommits: debugAll || true, + logStages: debugAll, + logCommits: debugAll, }; export const generatorLog = (logType: keyof typeof GeneratorDebug, ...messages: unknown[]) => { From 93c3c456f880ce6ff44bb651e14bf1ff6a9f8a84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rnar=20=C3=98sttveit?= Date: Fri, 8 Nov 2024 13:40:25 +0100 Subject: [PATCH 04/25] reduce ref hooks --- src/hooks/delayedSelectors.ts | 44 +++++++++++++++++++++-------------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/src/hooks/delayedSelectors.ts b/src/hooks/delayedSelectors.ts index fbafd4beec..cb1fa5a99f 100644 --- a/src/hooks/delayedSelectors.ts +++ b/src/hooks/delayedSelectors.ts @@ -16,6 +16,12 @@ type TypeFromConf = C extends DSConfig ? T : never; // eslint-disable-next-line @typescript-eslint/no-explicit-any type ModeFromConf = C extends DSConfig ? M : never; +type Internal = { + selectorsCalled: SelectorMap | null; + lastReRenderValue: unknown | null; + unsubscribe: (() => void) | null; +}; + /** * A complex hook that returns a function you can use to select a value at some point in the future. If you never * select any values from the store, the store will not be subscribed to, and the component will not re-render when @@ -36,25 +42,28 @@ export function useDelayedSelector({ equalityFn = deepEqual, onlyReRenderWhen, }: DSProps): DSReturn { - const selectorsCalled = useRef>(); const [renderCount, forceRerender] = useState(0); - const lastReRenderValue = useRef(undefined); - const unsubscribe = useRef<() => void>(); + const internal = useRef>({ + selectorsCalled: null, + lastReRenderValue: null, + unsubscribe: null, + }); - useEffect(() => () => unsubscribe.current?.(), []); + useEffect(() => () => internal.current.unsubscribe?.(), []); const subscribe = useCallback( () => store !== ContextNotProvided ? store.subscribe((state) => { - if (!selectorsCalled.current) { + const s = internal.current; + if (!s.selectorsCalled) { return; } let stateChanged = true; if (onlyReRenderWhen) { - stateChanged = onlyReRenderWhen(state, lastReRenderValue.current, (v) => { - lastReRenderValue.current = v; + stateChanged = onlyReRenderWhen(state, s.lastReRenderValue, (v) => { + s.lastReRenderValue = v; }); } if (!stateChanged) { @@ -63,7 +72,7 @@ export function useDelayedSelector({ // When the state changes, we run all the known selectors again to figure out if anything changed. If it // did change, we'll clear the list of selectors to force a re-render. - const selectors = selectorsCalled.current.values(); + const selectors = s.selectorsCalled.values(); let changed = false; for (const { fullSelector, value } of selectors) { if (!equalityFn(value, fullSelector(state))) { @@ -72,11 +81,11 @@ export function useDelayedSelector({ } } if (changed) { - selectorsCalled.current = undefined; + s.selectorsCalled = null; forceRerender((prev) => prev + 1); } }) - : undefined, + : null, // eslint-disable-next-line react-hooks/exhaustive-deps [store], ); @@ -89,6 +98,7 @@ export function useDelayedSelector({ } return ContextNotProvided; } + const s = internal.current; if (isNaN(renderCount)) { // This should not happen, and this piece of code looks a bit out of place. This really is only here @@ -97,7 +107,7 @@ export function useDelayedSelector({ } const cacheKey = makeCacheKey(args); - const prev = selectorsCalled.current?.get(cacheKey); + const prev = s.selectorsCalled?.get(cacheKey); if (prev) { // Performance-wise we could also just have called the selector here, it doesn't really matter. What is // important however, is that we let developers know as early as possible if they forgot to include a dependency @@ -108,11 +118,11 @@ export function useDelayedSelector({ // We don't need to initialize the arraymap before checking for the previous value, // since we know it would not exist if we just created it. - if (!selectorsCalled.current) { - selectorsCalled.current = new ShallowArrayMap(); + if (!s.selectorsCalled) { + s.selectorsCalled = new ShallowArrayMap(); } - if (!unsubscribe.current) { - unsubscribe.current = subscribe(); + if (!s.unsubscribe) { + s.unsubscribe = subscribe(); } const state = store.getState(); @@ -121,7 +131,7 @@ export function useDelayedSelector({ const { selector } = mode as SimpleArgMode; const fullSelector: Selector, unknown> = (state) => selector(...args)(state); const value = fullSelector(state); - selectorsCalled.current.set(cacheKey, { fullSelector, value }); + s.selectorsCalled.set(cacheKey, { fullSelector, value }); return value; } @@ -137,7 +147,7 @@ export function useDelayedSelector({ }; const value = fullSelector(state); - selectorsCalled.current.set(cacheKey, { fullSelector, value }); + s.selectorsCalled.set(cacheKey, { fullSelector, value }); return value; } From 307dc07a200586f2aef3c72218a6c23ce27379db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rnar=20=C3=98sttveit?= Date: Fri, 8 Nov 2024 16:12:12 +0100 Subject: [PATCH 05/25] trick react into finding host sibling faster :'( --- src/utils/layout/generator/NodeRepeatingChildren.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/utils/layout/generator/NodeRepeatingChildren.tsx b/src/utils/layout/generator/NodeRepeatingChildren.tsx index d119b3dbb4..e73876c743 100644 --- a/src/utils/layout/generator/NodeRepeatingChildren.tsx +++ b/src/utils/layout/generator/NodeRepeatingChildren.tsx @@ -61,9 +61,9 @@ function NodeRepeatingChildrenWorker({ ); return ( - <> + {Array.from({ length: numRows }).map((_, index) => ( - +

- +
))} - + ); } From 94cdff0bff3a8ee6de6e1d6fedcd4d3f86af9062 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rnar=20=C3=98sttveit?= Date: Wed, 13 Nov 2024 07:57:35 +0100 Subject: [PATCH 06/25] multi-delayed-selector --- src/core/contexts/zustandContext.tsx | 26 +- .../attachments/AttachmentsStorePlugin.tsx | 17 + src/features/formData/FormDataWrite.tsx | 30 ++ src/features/instance/InstanceContext.tsx | 6 + src/features/options/OptionsStorePlugin.tsx | 10 + .../nodeValidation/useNodeValidation.ts | 2 +- src/features/validation/validationContext.tsx | 36 +- src/hooks/delayedSelectors.ts | 365 +++++++++++++++++- src/utils/layout/NodesContext.tsx | 5 + .../layout/generator/GeneratorDataSources.tsx | 63 ++- src/utils/layout/generator/debug.ts | 4 +- 11 files changed, 535 insertions(+), 29 deletions(-) diff --git a/src/core/contexts/zustandContext.tsx b/src/core/contexts/zustandContext.tsx index 5103e0e54d..3e8cbad274 100644 --- a/src/core/contexts/zustandContext.tsx +++ b/src/core/contexts/zustandContext.tsx @@ -6,9 +6,9 @@ import { createStore, useStore } from 'zustand'; import type { StoreApi } from 'zustand'; import { ContextNotProvided, createContext } from 'src/core/contexts/context'; -import { SelectorStrictness, useDelayedSelector } from 'src/hooks/delayedSelectors'; +import { SelectorStrictness, useDelayedSelector2 } from 'src/hooks/delayedSelectors'; import type { CreateContextProps } from 'src/core/contexts/context'; -import type { DSConfig, DSMode, DSReturn } from 'src/hooks/delayedSelectors'; +import type { DSConfig, DSMode, DSProps, DSReturn } from 'src/hooks/delayedSelectors'; type ExtractFromStoreApi = T extends StoreApi ? Exclude : never; @@ -147,7 +147,7 @@ export function createZustandContext, Type = Extrac // eslint-disable-next-line @typescript-eslint/no-explicit-any deps?: any[], ): DSReturn> => - useDelayedSelector({ + useDelayedSelector2({ store: useLaxCtx(), strictness: SelectorStrictness.returnWhenNotProvided, mode, @@ -158,13 +158,29 @@ export function createZustandContext, Type = Extrac mode: Mode, deps?: unknown[], ): DSReturn> => - useDelayedSelector({ + useDelayedSelector2({ store: useCtx(), strictness: SelectorStrictness.throwWhenNotProvided, mode, deps, }); + const useDSProto = >( + mode: Mode, + ): DSProps> => ({ + store: useCtx(), + strictness: SelectorStrictness.throwWhenNotProvided, + mode, + }); + + const useLaxDSProto = >( + mode: Mode, + ): DSProps> => ({ + store: useLaxCtx(), + strictness: SelectorStrictness.returnWhenNotProvided, + mode, + }); + return { Provider: MyProvider, useSelector, @@ -175,6 +191,8 @@ export function createZustandContext, Type = Extrac useLaxSelector, useDelayedSelector: useDS, useLaxDelayedSelector: useLaxDS, + useDelayedSelectorProto: useDSProto, + useLaxDelayedSelectorProto: useLaxDSProto, useHasProvider, useStore: useCtx, useLaxStore: useLaxCtx, diff --git a/src/features/attachments/AttachmentsStorePlugin.tsx b/src/features/attachments/AttachmentsStorePlugin.tsx index 525794ceb8..181561262f 100644 --- a/src/features/attachments/AttachmentsStorePlugin.tsx +++ b/src/features/attachments/AttachmentsStorePlugin.tsx @@ -33,6 +33,7 @@ import type { UploadedAttachment, } from 'src/features/attachments/index'; import type { BackendValidationIssue } from 'src/features/validation'; +import type { DSConfig, DSProps } from 'src/hooks/delayedSelectors'; import type { IDataModelBindingsList, IDataModelBindingsSimple } from 'src/layout/common.generated'; import type { CompWithBehavior } from 'src/layout/layout'; import type { IData } from 'src/types/shared'; @@ -83,6 +84,7 @@ export interface AttachmentsStorePluginConfig { useAttachments: (node: FileUploaderNode) => IAttachment[]; useAttachmentsSelector: () => AttachmentsSelector; + useAttachmentsSelectorProto: () => DSProps; useWaitUntilUploaded: () => (node: FileUploaderNode, attachment: TemporaryAttachment) => Promise; useHasPendingAttachments: () => boolean; @@ -399,6 +401,21 @@ export class AttachmentsStorePlugin extends NodeDataPlugin (state) => { + const nodeData = state.nodeData[node.id]; + if (!nodeData) { + return emptyArray; + } + if (nodeData && 'attachments' in nodeData) { + return Object.values(nodeData.attachments).sort(sortAttachmentsByName); + } + return emptyArray; + }, + }); + }, useWaitUntilUploaded() { const zustandStore = store.useStore(); const waitFor = useWaitForState(zustandStore); diff --git a/src/features/formData/FormDataWrite.tsx b/src/features/formData/FormDataWrite.tsx index 23a920c0e2..6f41efb50a 100644 --- a/src/features/formData/FormDataWrite.tsx +++ b/src/features/formData/FormDataWrite.tsx @@ -64,6 +64,7 @@ const { useLaxMemoSelector, useLaxDelayedSelector, useDelayedSelector, + useDelayedSelectorProto, useLaxSelector, useLaxStore, useStore, @@ -562,6 +563,13 @@ export const FD = { }); }, + useDebouncedSelectorProto() { + return useDelayedSelectorProto({ + mode: 'simple', + selector: debouncedSelector, + }); + }, + /** * The same as useDebouncedSelector(), but will return BaseRow[] instead of the raw data. This is useful if you * just want to fetch the number of rows, and the indexes/uuids of those rows, without fetching the actual data @@ -582,6 +590,21 @@ export const FD = { }); }, + useDebouncedRowsSelectorProto() { + return useDelayedSelectorProto({ + mode: 'simple', + selector: (reference: IDataModelReference) => (state) => { + const rawRows = dot.pick(reference.field, state.dataModels[reference.dataType].debouncedCurrentData); + if (!Array.isArray(rawRows) || !rawRows.length) { + return emptyArray; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return rawRows.map((row: any, index: number) => ({ uuid: row[ALTINN_ROW_ID], index })); + }, + }); + }, + /** * Same as useDebouncedSelector(), but for invalid data. */ @@ -592,6 +615,13 @@ export const FD = { }); }, + useInvalidDebouncedSelectorProto() { + return useDelayedSelectorProto({ + mode: 'simple', + selector: invalidDebouncedSelector, + }); + }, + /** * This will return the form data as a deep object, just like the server sends it to us (and the way we send it back). * This will always give you the debounced data, which may or may not be saved to the backend yet. diff --git a/src/features/instance/InstanceContext.tsx b/src/features/instance/InstanceContext.tsx index f5e8d01a0d..589dc0d0ec 100644 --- a/src/features/instance/InstanceContext.tsx +++ b/src/features/instance/InstanceContext.tsx @@ -52,6 +52,7 @@ const { useHasProvider, useLaxStore, useLaxDelayedSelector, + useLaxDelayedSelectorProto, } = createZustandContext({ name: 'InstanceContext', required: true, @@ -246,6 +247,11 @@ export const useLaxDataElementsSelector = (): DataElementSelector => mode: 'innerSelector', makeArgs: (state) => [state.data?.data ?? emptyArray], }); +export const useLaxDataElementsSelectorProto = () => + useLaxDelayedSelectorProto({ + mode: 'innerSelector', + makeArgs: (state) => [state.data?.data ?? emptyArray], + }); /** Like useLaxInstanceAllDataElements, but will never re-render when the data changes */ export const useLaxInstanceAllDataElementsNow = () => { diff --git a/src/features/options/OptionsStorePlugin.tsx b/src/features/options/OptionsStorePlugin.tsx index 7594bf0eaf..9bab98ab82 100644 --- a/src/features/options/OptionsStorePlugin.tsx +++ b/src/features/options/OptionsStorePlugin.tsx @@ -1,5 +1,6 @@ import { NodeDataPlugin } from 'src/utils/layout/plugins/NodeDataPlugin'; import type { IOptionInternal } from 'src/features/options/castOptionsToStrings'; +import type { DSConfig, DSProps } from 'src/hooks/delayedSelectors'; import type { CompWithBehavior } from 'src/layout/layout'; import type { LayoutNode } from 'src/utils/layout/LayoutNode'; import type { NodesStoreFull } from 'src/utils/layout/NodesContext'; @@ -16,6 +17,7 @@ export interface OptionsStorePluginConfig { extraHooks: { useNodeOptions: NodeOptionsSelector; useNodeOptionsSelector: () => NodeOptionsSelector; + useNodeOptionsSelectorProto: () => DSProps; }; } @@ -51,6 +53,14 @@ export class OptionsStorePlugin extends NodeDataPlugin return { isFetching: nodeDataToIsFetching(store), options: nodeDataToOptions(store) }; }, }), + useNodeOptionsSelectorProto: () => + store.useDelayedSelectorProto({ + mode: 'simple', + selector: (node: LayoutNode> | undefined) => (state) => { + const store = node ? state.nodeData[node.id] : undefined; + return { isFetching: nodeDataToIsFetching(store), options: nodeDataToOptions(store) }; + }, + }), }; } } diff --git a/src/features/validation/nodeValidation/useNodeValidation.ts b/src/features/validation/nodeValidation/useNodeValidation.ts index 19ef9ced78..910e2401a1 100644 --- a/src/features/validation/nodeValidation/useNodeValidation.ts +++ b/src/features/validation/nodeValidation/useNodeValidation.ts @@ -30,7 +30,7 @@ export function useNodeValidation( shouldValidate: boolean, ): { validations: AnyValidation[]; processedLast: ValidationsProcessedLast } { const dataModelSelector = Validation.useDataModelSelector(); - const validationDataSources = useValidationDataSources(); + const validationDataSources = GeneratorData.useValidationDataSources(); const nodeDataSelector = NodesInternal.useNodeDataSelector(); const getDataElementIdForDataType = GeneratorData.useGetDataElementIdForDataType(); diff --git a/src/features/validation/validationContext.tsx b/src/features/validation/validationContext.tsx index 0758dff7d7..18c66e5a17 100644 --- a/src/features/validation/validationContext.tsx +++ b/src/features/validation/validationContext.tsx @@ -142,12 +142,20 @@ function initialCreateStore() { ); } -const { Provider, useSelector, useLaxSelector, useSelectorAsRef, useStore, useLaxSelectorAsRef, useDelayedSelector } = - createZustandContext({ - name: 'Validation', - required: true, - initialCreateStore, - }); +const { + Provider, + useSelector, + useLaxSelector, + useSelectorAsRef, + useStore, + useLaxSelectorAsRef, + useDelayedSelector, + useDelayedSelectorProto, +} = createZustandContext({ + name: 'Validation', + required: true, + initialCreateStore, +}); export function ValidationProvider({ children }: PropsWithChildren) { const writableDataTypes = DataModels.useWritableDataTypes(); @@ -335,6 +343,22 @@ export const Validation = { }, }), + useDataElementHasErrorsSelectorProto: () => + useDelayedSelectorProto({ + mode: 'simple', + selector: (dataElementId: string) => (state) => { + const dataElementValidations = state.state.dataModels[dataElementId]; + for (const fieldValidations of Object.values(dataElementValidations ?? {})) { + for (const validation of fieldValidations) { + if (validation.severity === 'error') { + return true; + } + } + } + return false; + }, + }), + useShowAllBackendErrors: () => useSelector((state) => state.showAllBackendErrors), useSetShowAllBackendErrors: () => useLaxSelector((state) => async () => { diff --git a/src/hooks/delayedSelectors.ts b/src/hooks/delayedSelectors.ts index cb1fa5a99f..566396843a 100644 --- a/src/hooks/delayedSelectors.ts +++ b/src/hooks/delayedSelectors.ts @@ -1,4 +1,5 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'; +import type { MutableRefObject } from 'react'; import deepEqual from 'fast-deep-equal'; import type { StoreApi } from 'zustand'; @@ -158,6 +159,368 @@ export function useDelayedSelector({ ) as DSReturn; } +class DelayedSelectorProto { + private name: string | undefined; + private store: C['store']; + private strictness: C['strictness']; + private mode: C['mode']; + private makeCacheKey: (args: unknown[]) => unknown[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private equalityFn: (a: any, b: any) => boolean; + private onlyReRenderWhen: OnlyReRenderWhen, unknown> | undefined; + private deps: unknown[] | undefined; + + private lastReRenderValue: unknown = null; + private selectorsCalled: SelectorMap | null = null; + private unsubscribeMethod: (() => void) | null = null; + + private changeCount = 0; + private lastSelectChangeCount = 0; + + private onChange: (lastSelectChangeCount: number, name?: string) => void; + + constructor( + { + store, + strictness, + mode, + makeCacheKey = mode.mode === 'simple' ? defaultMakeCacheKey : defaultMakeCacheKeyForInnerSelector, + equalityFn = deepEqual, + onlyReRenderWhen, + deps, + }: DSProps, + onChange: (lastSelectChangeCount: number, name?: string) => void, + name?: string, + ) { + this.name = name; + this.store = store; + this.strictness = strictness; + this.mode = mode; + this.makeCacheKey = makeCacheKey; + this.equalityFn = equalityFn; + this.onlyReRenderWhen = onlyReRenderWhen; + this.deps = deps; + this.onChange = onChange; + } + + public unsubscribe() { + if (this.unsubscribeMethod) { + this.unsubscribeMethod(); + this.unsubscribeMethod = null; + } + } + + public getSelector() { + return ((...args: unknown[]) => this.selector(...args)) as DSReturn; + } + + public setChangeCount(i: number) { + this.changeCount = i; + } + + public checkDeps(newProps: DSProps) { + if (newProps.deps && !arrayShallowEqual(newProps.deps, this.deps)) { + const { + store, + strictness, + mode, + makeCacheKey = mode.mode === 'simple' ? defaultMakeCacheKey : defaultMakeCacheKeyForInnerSelector, + equalityFn = deepEqual, + onlyReRenderWhen, + deps, + } = newProps; + + this.store = store; + this.strictness = strictness; + this.mode = mode; + this.makeCacheKey = makeCacheKey; + this.equalityFn = equalityFn; + this.onlyReRenderWhen = onlyReRenderWhen; + this.deps = deps; + + this.selectorsCalled = null; + this.unsubscribe(); + this.onChange(this.lastSelectChangeCount); + } + } + + private subscribe() { + if (this.store === ContextNotProvided) { + return null; + } + return this.store.subscribe((state) => { + if (!this.selectorsCalled) { + return; + } + + let stateChanged = true; + if (this.onlyReRenderWhen) { + stateChanged = this.onlyReRenderWhen(state, this.lastReRenderValue, (v) => { + this.lastReRenderValue = v; + }); + } + if (!stateChanged) { + return; + } + + // When the state changes, we run all the known selectors again to figure out if anything changed. If it + // did change, we'll clear the list of selectors to force a re-render. + const selectors = this.selectorsCalled.values(); + let changed = false; + for (const { fullSelector, value } of selectors) { + if (!this.equalityFn(value, fullSelector(state))) { + changed = true; + break; + } + } + if (changed) { + this.selectorsCalled = null; + this.unsubscribe(); + this.onChange(this.lastSelectChangeCount, this.name); + } + }); + } + + public selector(...args: unknown[]) { + if (this.store === ContextNotProvided) { + if (this.strictness === SelectorStrictness.throwWhenNotProvided) { + throw new Error('useDelayedSelector: store not provided'); + } + return ContextNotProvided; + } + + this.lastSelectChangeCount = this.changeCount; + + const cacheKey = this.makeCacheKey(args); + const prev = this.selectorsCalled?.get(cacheKey); + if (prev) { + // Performance-wise we could also just have called the selector here, it doesn't really matter. What is + // important however, is that we let developers know as early as possible if they forgot to include a dependency + // or otherwise used the hook incorrectly, so we'll make sure to return the value to them here even if it + // could be stale (but only when improperly used). + return prev.value; + } + + // We don't need to initialize the arraymap before checking for the previous value, + // since we know it would not exist if we just created it. + if (!this.selectorsCalled) { + this.selectorsCalled = new ShallowArrayMap(); + } + if (!this.unsubscribeMethod) { + this.unsubscribeMethod = this.subscribe(); + } + + const state = this.store.getState(); + + if (this.mode.mode === 'simple') { + const { selector } = this.mode as SimpleArgMode; + const fullSelector: Selector, unknown> = (state) => selector(...args)(state); + const value = fullSelector(state); + this.selectorsCalled.set(cacheKey, { fullSelector, value }); + return value; + } + + if (this.mode.mode === 'innerSelector') { + const { makeArgs } = this.mode as InnerSelectorMode; + if (typeof args[0] !== 'function' || !Array.isArray(args[1]) || args.length !== 2) { + throw new Error('useDelayedSelector: innerSelector must be a function'); + } + const fullSelector: Selector, unknown> = (state) => { + const innerArgs = makeArgs(state); + const innerSelector = args[0] as (...args: typeof innerArgs) => unknown; + return innerSelector(...innerArgs); + }; + + const value = fullSelector(state); + this.selectorsCalled.set(cacheKey, { fullSelector, value }); + return value; + } + + throw new Error('useDelayedSelector: invalid mode'); + } +} + +type MDSProps = { + [name: string]: DSProps; +}; + +type MDSSTate

= { + changeCount: number; + delayedSelectors: { [name in keyof P]?: DelayedSelectorProto }; + snapshot: { [name in keyof P]: DSReturn }; + subscribe: (callback: () => void) => () => void; + getSnapshot: () => { [name in keyof P]: DSReturn }; + forceRerender: () => void; + hasDeps: (keyof P)[]; +}; + +function initMultiSelectorState

(props: P, state: MutableRefObject>): MDSSTate

{ + const subscribe = (callback: () => void) => { + state.current.forceRerender = callback; + return () => Object.values(state.current.delayedSelectors).forEach((ds) => ds?.unsubscribe()); + }; + + const getSnapshot = () => state.current.snapshot; + + const onChange = (lastSelectRenderCount: number, name?: string) => { + // Prevent multiple re-renders at the same time + state.current.snapshot[name as keyof P] = state.current.delayedSelectors[name as keyof P]!.getSelector(); + if (lastSelectRenderCount === state.current.changeCount) { + state.current.snapshot = { ...state.current.snapshot }; + state.current.forceRerender(); + } + state.current.changeCount += 1; + Object.values(state.current.delayedSelectors).forEach((ds) => ds.setChangeCount(state.current.changeCount)); + }; + + // const [delayedSelectors, snapshot, hasDeps] = makeDelayedSelectors(props, onChange); + + const initialSnapshot = {}; + const hasDeps: (keyof P)[] = []; + for (const name in props) { + const prop = props[name]; + !!prop.deps && hasDeps.push(name); + initialSnapshot[name as string] = (...args: unknown[]) => { + let delayedSelector = state.current.delayedSelectors[name]; + if (!delayedSelector) { + delayedSelector = new DelayedSelectorProto(prop, onChange, name) as DelayedSelectorProto; + state.current.delayedSelectors[name] = delayedSelector; + } + return delayedSelector.selector(...args); + }; + } + + return { + changeCount: 0, + delayedSelectors: {}, + snapshot: initialSnapshot as { [name in keyof P]: DSReturn }, + subscribe, + getSnapshot, + forceRerender: () => {}, + hasDeps, + }; +} + +export function useMultipleDelayedSelectors

(props: P) { + const state: MutableRefObject> = useRef( + initMultiSelectorState(props, { + get current() { + return state.current; + }, + }), + ); + + // Check if any deps have changed + if (state.current.hasDeps.length) { + for (const name of state.current.hasDeps) { + state.current.delayedSelectors[name]?.checkDeps(props[name] as never); + } + } + + return useSyncExternalStore(state.current.subscribe, state.current.getSnapshot) as { + [name in keyof P]: DSReturn; + }; +} + +type DSSTate = { + delayedSelector?: DelayedSelectorProto; + snapshot: DSReturn; + subscribe: (callback: () => void) => () => void; + getSnapshot: () => DSReturn; + forceRerender: () => void; + hasDeps: boolean; +}; + +function initSelectorState(props: DSProps, state: MutableRefObject>): DSSTate { + const subscribe = (callback: () => void) => { + state.current.forceRerender = callback; + return () => state.current.delayedSelector?.unsubscribe(); + }; + + const getSnapshot = () => state.current.snapshot; + + const onChange = () => { + state.current.snapshot = state.current.delayedSelector!.getSelector(); + state.current.forceRerender(); + }; + + const initialSnapshot = (...args: unknown[]) => { + let delayedSelector = state.current.delayedSelector; + if (!delayedSelector) { + delayedSelector = new DelayedSelectorProto(props, onChange); + state.current.delayedSelector = delayedSelector; + } + return delayedSelector.selector(...args); + }; + + const hasDeps = !!props.deps; + + return { + snapshot: initialSnapshot as DSReturn, + subscribe, + getSnapshot, + forceRerender: () => {}, + hasDeps, + }; +} + +export function useDelayedSelector2(props: DSProps): DSReturn { + const state: MutableRefObject> = useRef( + initSelectorState(props, { + get current() { + return state.current; + }, + }), + ); + + // Check if any deps have changed + if (state.current.hasDeps) { + state.current.delayedSelector?.checkDeps(props); + } + + return useSyncExternalStore(state.current.subscribe, state.current.getSnapshot); +} + +// function makeDelayedSelectors

(props: P, onChange: (lastSelectRenderCount: number) => void) { +// const delayedSelectors = {}; +// const selectors = {}; +// const hasDeps: (keyof P)[] = []; +// for (const name in props) { +// const prop = props[name]; +// !!prop.deps && hasDeps.push(name); +// const ds = new DelayedSelectorProto(prop, onChange, name); +// delayedSelectors[name as string] = ds; +// selectors[name as string] = ds.getSelector(); +// } +// return [delayedSelectors, selectors, hasDeps] as [ +// MDSSTate

['delayedSelectors'], +// MDSSTate

['snapshot'], +// (keyof P)[], +// ]; +// } +// +// function getFreshSelectors

(delayedSelectors: { +// [name in keyof P]?: DelayedSelectorProto; +// }) { +// const selectors = {}; +// for (const name in delayedSelectors) { +// selectors[name as string] = delayedSelectors[name]?.getSelector(); +// } +// return selectors as MDSSTate

['snapshot']; +// } + +function arrayShallowEqual(a: unknown[], b?: unknown[]) { + if (a.length !== b?.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) { + return false; + } + } + return true; +} + function defaultMakeCacheKeyForInnerSelector(args: unknown[]): unknown[] { if (args.length === 2 && typeof args[0] === 'function' && Array.isArray(args[1])) { return [args[0].toString().trim(), ...args[1]]; diff --git a/src/utils/layout/NodesContext.tsx b/src/utils/layout/NodesContext.tsx index dd67e32629..fdee3c8fc0 100644 --- a/src/utils/layout/NodesContext.tsx +++ b/src/utils/layout/NodesContext.tsx @@ -1185,6 +1185,11 @@ export const NodesInternal = { mode: 'innerSelector', makeArgs: (state) => [((node) => selectNodeData(node, state)) satisfies NodePicker], }), + useNodeDataSelectorProto: () => + Store.useDelayedSelectorProto({ + mode: 'innerSelector', + makeArgs: (state) => [((node) => selectNodeData(node, state)) satisfies NodePicker], + }), useTypeFromId: (id: string) => Store.useSelector((s) => s.nodeData[id]?.layout.type), useIsAdded: (node: LayoutNode | LayoutPage | undefined) => Store.useSelector((s) => { diff --git a/src/utils/layout/generator/GeneratorDataSources.tsx b/src/utils/layout/generator/GeneratorDataSources.tsx index 7f7355b9c4..542780ac55 100644 --- a/src/utils/layout/generator/GeneratorDataSources.tsx +++ b/src/utils/layout/generator/GeneratorDataSources.tsx @@ -4,19 +4,22 @@ import { useApplicationSettings } from 'src/features/applicationSettings/Applica import { DataModels } from 'src/features/datamodel/DataModelsProvider'; import { useDevToolsStore } from 'src/features/devtools/data/DevToolsStore'; import { useExternalApis } from 'src/features/externalApi/useExternalApi'; +import { useLayoutSets } from 'src/features/form/layoutSets/LayoutSetsProvider'; import { useCurrentLayoutSet } from 'src/features/form/layoutSets/useCurrentLayoutSet'; import { FD } from 'src/features/formData/FormDataWrite'; -import { useLaxInstanceDataSources } from 'src/features/instance/InstanceContext'; +import { useLaxDataElementsSelectorProto, useLaxInstanceDataSources } from 'src/features/instance/InstanceContext'; import { useLaxProcessData } from 'src/features/instance/ProcessContext'; import { useCurrentLanguage } from 'src/features/language/LanguageProvider'; import { useInnerLanguageWithForcedNodeSelector } from 'src/features/language/useLanguage'; import { Validation } from 'src/features/validation/validationContext'; +import { useMultipleDelayedSelectors } from 'src/hooks/delayedSelectors'; import { useShallowObjectMemo } from 'src/hooks/useShallowObjectMemo'; import { useCommitWhenFinished } from 'src/utils/layout/generator/CommitQueue'; import { Hidden, NodesInternal, useNodes } from 'src/utils/layout/NodesContext'; import { useInnerDataModelBindingTranspose } from 'src/utils/layout/useDataModelBindingTranspose'; import { useInnerNodeFormDataSelector } from 'src/utils/layout/useNodeItem'; import { useInnerNodeTraversalSelector } from 'src/utils/layout/useNodeTraversal'; +import type { ValidationDataSources } from 'src/features/validation'; import type { ExpressionDataSources } from 'src/utils/layout/useExpressionDataSources'; const { Provider, hooks } = createHookContext({ @@ -41,6 +44,7 @@ const { Provider, hooks } = createHookContext({ export const GeneratorData = { Provider, useExpressionDataSources, + useValidationDataSources, useDefaultDataType: hooks.useDefaultDataType, useIsForcedVisibleByDevTools: hooks.useIsForcedVisibleByDevTools, @@ -51,11 +55,13 @@ export const GeneratorData = { }; function useExpressionDataSources(): ExpressionDataSources { - const formDataSelector = FD.useDebouncedSelector(); - const formDataRowsSelector = FD.useDebouncedRowsSelector(); - const attachmentsSelector = NodesInternal.useAttachmentsSelector(); - const optionsSelector = NodesInternal.useNodeOptionsSelector(); - const nodeDataSelector = NodesInternal.useNodeDataSelector(); + const selectors = useMultipleDelayedSelectors({ + formDataSelector: FD.useDebouncedSelectorProto(), + formDataRowsSelector: FD.useDebouncedRowsSelectorProto(), + attachmentsSelector: NodesInternal.useAttachmentsSelectorProto(), + optionsSelector: NodesInternal.useNodeOptionsSelectorProto(), + nodeDataSelector: NodesInternal.useNodeDataSelectorProto(), + }); const process = useLaxProcessData(); const applicationSettings = useApplicationSettings(); @@ -68,32 +74,59 @@ function useExpressionDataSources(): ExpressionDataSources { const isHiddenSelector = Hidden.useIsHiddenSelector(); const nodeTraversal = useInnerNodeTraversalSelector(hooks.useNodes()); - const transposeSelector = useInnerDataModelBindingTranspose(nodeDataSelector); - const nodeFormDataSelector = useInnerNodeFormDataSelector(nodeDataSelector, formDataSelector); + const transposeSelector = useInnerDataModelBindingTranspose(selectors.nodeDataSelector); + const nodeFormDataSelector = useInnerNodeFormDataSelector(selectors.nodeDataSelector, selectors.formDataSelector); const langToolsSelector = useInnerLanguageWithForcedNodeSelector( hooks.useDefaultDataType(), dataModelNames, - formDataSelector, - nodeDataSelector, + selectors.formDataSelector, + selectors.nodeDataSelector, ); return useShallowObjectMemo({ - formDataSelector, - formDataRowsSelector, - attachmentsSelector, + formDataSelector: selectors.formDataSelector, + formDataRowsSelector: selectors.formDataRowsSelector, + attachmentsSelector: selectors.attachmentsSelector, + optionsSelector: selectors.optionsSelector, + nodeDataSelector: selectors.nodeDataSelector, process, - optionsSelector, applicationSettings, instanceDataSources, langToolsSelector, currentLanguage, isHiddenSelector, nodeFormDataSelector, - nodeDataSelector, nodeTraversal, transposeSelector, currentLayoutSet, externalApis, dataModelNames, + }) as ExpressionDataSources; +} + +function useValidationDataSources(): ValidationDataSources { + const selectors = useMultipleDelayedSelectors({ + formDataSelector: FD.useDebouncedSelectorProto(), + invalidDataSelector: FD.useInvalidDebouncedSelectorProto(), + attachmentsSelector: NodesInternal.useAttachmentsSelectorProto(), + nodeDataSelector: NodesInternal.useNodeDataSelectorProto(), + dataElementsSelector: useLaxDataElementsSelectorProto(), + dataElementHasErrorsSelector: Validation.useDataElementHasErrorsSelectorProto(), }); + + const currentLanguage = useCurrentLanguage(); + const applicationMetadata = useApplicationMetadata(); + const layoutSets = useLayoutSets(); + + return useShallowObjectMemo({ + formDataSelector: selectors.formDataSelector, + invalidDataSelector: selectors.invalidDataSelector, + attachmentsSelector: selectors.attachmentsSelector, + nodeDataSelector: selectors.nodeDataSelector, + dataElementsSelector: selectors.dataElementsSelector, + dataElementHasErrorsSelector: selectors.dataElementHasErrorsSelector, + currentLanguage, + applicationMetadata, + layoutSets, + }) as ValidationDataSources; } diff --git a/src/utils/layout/generator/debug.ts b/src/utils/layout/generator/debug.ts index 115f06b783..b57e234a53 100644 --- a/src/utils/layout/generator/debug.ts +++ b/src/utils/layout/generator/debug.ts @@ -3,8 +3,8 @@ export const GeneratorDebug = { displayState: debugAll, displayReadiness: debugAll, logReadiness: debugAll, - logStages: debugAll, - logCommits: debugAll, + logStages: debugAll || true, + logCommits: debugAll || true, }; export const generatorLog = (logType: keyof typeof GeneratorDebug, ...messages: unknown[]) => { From c34025ae9ea406718566b821fe9c53273e64572f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rnar=20=C3=98sttveit?= Date: Wed, 13 Nov 2024 10:25:11 +0100 Subject: [PATCH 07/25] delayed selector class implementation --- src/core/contexts/zustandContext.tsx | 6 +- src/hooks/delayedSelectors.ts | 428 +++++------------- .../layout/generator/GeneratorDataSources.tsx | 68 +-- 3 files changed, 149 insertions(+), 353 deletions(-) diff --git a/src/core/contexts/zustandContext.tsx b/src/core/contexts/zustandContext.tsx index 3e8cbad274..98b73b3086 100644 --- a/src/core/contexts/zustandContext.tsx +++ b/src/core/contexts/zustandContext.tsx @@ -6,7 +6,7 @@ import { createStore, useStore } from 'zustand'; import type { StoreApi } from 'zustand'; import { ContextNotProvided, createContext } from 'src/core/contexts/context'; -import { SelectorStrictness, useDelayedSelector2 } from 'src/hooks/delayedSelectors'; +import { SelectorStrictness, useDelayedSelector } from 'src/hooks/delayedSelectors'; import type { CreateContextProps } from 'src/core/contexts/context'; import type { DSConfig, DSMode, DSProps, DSReturn } from 'src/hooks/delayedSelectors'; @@ -147,7 +147,7 @@ export function createZustandContext, Type = Extrac // eslint-disable-next-line @typescript-eslint/no-explicit-any deps?: any[], ): DSReturn> => - useDelayedSelector2({ + useDelayedSelector({ store: useLaxCtx(), strictness: SelectorStrictness.returnWhenNotProvided, mode, @@ -158,7 +158,7 @@ export function createZustandContext, Type = Extrac mode: Mode, deps?: unknown[], ): DSReturn> => - useDelayedSelector2({ + useDelayedSelector({ store: useCtx(), strictness: SelectorStrictness.throwWhenNotProvided, mode, diff --git a/src/hooks/delayedSelectors.ts b/src/hooks/delayedSelectors.ts index 566396843a..cb22ba0c9b 100644 --- a/src/hooks/delayedSelectors.ts +++ b/src/hooks/delayedSelectors.ts @@ -1,5 +1,4 @@ -import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'; -import type { MutableRefObject } from 'react'; +import { useRef, useSyncExternalStore } from 'react'; import deepEqual from 'fast-deep-equal'; import type { StoreApi } from 'zustand'; @@ -17,12 +16,6 @@ type TypeFromConf = C extends DSConfig ? T : never; // eslint-disable-next-line @typescript-eslint/no-explicit-any type ModeFromConf = C extends DSConfig ? M : never; -type Internal = { - selectorsCalled: SelectorMap | null; - lastReRenderValue: unknown | null; - unsubscribe: (() => void) | null; -}; - /** * A complex hook that returns a function you can use to select a value at some point in the future. If you never * select any values from the store, the store will not be subscribed to, and the component will not re-render when @@ -34,133 +27,25 @@ type Internal = { * because the function itself will be recreated every time the component re-renders, and the function * will not be able to be used as a cache key. */ -export function useDelayedSelector({ - store, - deps = [], - strictness, - mode, - makeCacheKey = mode.mode === 'simple' ? defaultMakeCacheKey : defaultMakeCacheKeyForInnerSelector, - equalityFn = deepEqual, - onlyReRenderWhen, -}: DSProps): DSReturn { - const [renderCount, forceRerender] = useState(0); - const internal = useRef>({ - selectorsCalled: null, - lastReRenderValue: null, - unsubscribe: null, - }); - - useEffect(() => () => internal.current.unsubscribe?.(), []); - - const subscribe = useCallback( - () => - store !== ContextNotProvided - ? store.subscribe((state) => { - const s = internal.current; - if (!s.selectorsCalled) { - return; - } - - let stateChanged = true; - if (onlyReRenderWhen) { - stateChanged = onlyReRenderWhen(state, s.lastReRenderValue, (v) => { - s.lastReRenderValue = v; - }); - } - if (!stateChanged) { - return; - } - - // When the state changes, we run all the known selectors again to figure out if anything changed. If it - // did change, we'll clear the list of selectors to force a re-render. - const selectors = s.selectorsCalled.values(); - let changed = false; - for (const { fullSelector, value } of selectors) { - if (!equalityFn(value, fullSelector(state))) { - changed = true; - break; - } - } - if (changed) { - s.selectorsCalled = null; - forceRerender((prev) => prev + 1); - } - }) - : null, - // eslint-disable-next-line react-hooks/exhaustive-deps - [store], - ); - - return useCallback( - (...args: unknown[]) => { - if (store === ContextNotProvided) { - if (strictness === SelectorStrictness.throwWhenNotProvided) { - throw new Error('useDelayedSelector: store not provided'); - } - return ContextNotProvided; - } - const s = internal.current; - - if (isNaN(renderCount)) { - // This should not happen, and this piece of code looks a bit out of place. This really is only here - // to make sure the callback is re-created and the component re-renders when the store changes. - throw new Error('useDelayedSelector: renderCount is NaN'); - } +export function useDelayedSelector(props: DSProps): DSReturn { + const state = useRef(new SingleDelayedSelectorController(props)); - const cacheKey = makeCacheKey(args); - const prev = s.selectorsCalled?.get(cacheKey); - if (prev) { - // Performance-wise we could also just have called the selector here, it doesn't really matter. What is - // important however, is that we let developers know as early as possible if they forgot to include a dependency - // or otherwise used the hook incorrectly, so we'll make sure to return the value to them here even if it - // could be stale (but only when improperly used). - return prev.value; - } - - // We don't need to initialize the arraymap before checking for the previous value, - // since we know it would not exist if we just created it. - if (!s.selectorsCalled) { - s.selectorsCalled = new ShallowArrayMap(); - } - if (!s.unsubscribe) { - s.unsubscribe = subscribe(); - } + // Check if any deps have changed + state.current.checkDeps(props); - const state = store.getState(); + return useSyncExternalStore(state.current.subscribe, state.current.getSnapshot); +} - if (mode.mode === 'simple') { - const { selector } = mode as SimpleArgMode; - const fullSelector: Selector, unknown> = (state) => selector(...args)(state); - const value = fullSelector(state); - s.selectorsCalled.set(cacheKey, { fullSelector, value }); - return value; - } +export function useMultipleDelayedSelectors

(...props: P): { [I in keyof P]: DSReturn } { + const state = useRef(new MultiDelayedSelectorController(props)); - if (mode.mode === 'innerSelector') { - const { makeArgs } = mode as InnerSelectorMode; - if (typeof args[0] !== 'function' || !Array.isArray(args[1]) || args.length !== 2) { - throw new Error('useDelayedSelector: innerSelector must be a function'); - } - const fullSelector: Selector, unknown> = (state) => { - const innerArgs = makeArgs(state); - const innerSelector = args[0] as (...args: typeof innerArgs) => unknown; - return innerSelector(...innerArgs); - }; - - const value = fullSelector(state); - s.selectorsCalled.set(cacheKey, { fullSelector, value }); - return value; - } + // Check if any deps have changed + state.current.checkDeps(props); - throw new Error('useDelayedSelector: invalid mode'); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [store, renderCount, ...deps], - ) as DSReturn; + return useSyncExternalStore(state.current.subscribe, state.current.getSnapshot); } -class DelayedSelectorProto { - private name: string | undefined; +abstract class BaseDelayedSelector { private store: C['store']; private strictness: C['strictness']; private mode: C['mode']; @@ -170,29 +55,20 @@ class DelayedSelectorProto { private onlyReRenderWhen: OnlyReRenderWhen, unknown> | undefined; private deps: unknown[] | undefined; + protected selectorFunc = ((...args: unknown[]) => this.selector(...args)) as DSReturn; private lastReRenderValue: unknown = null; private selectorsCalled: SelectorMap | null = null; private unsubscribeMethod: (() => void) | null = null; - private changeCount = 0; - private lastSelectChangeCount = 0; - - private onChange: (lastSelectChangeCount: number, name?: string) => void; - - constructor( - { - store, - strictness, - mode, - makeCacheKey = mode.mode === 'simple' ? defaultMakeCacheKey : defaultMakeCacheKeyForInnerSelector, - equalityFn = deepEqual, - onlyReRenderWhen, - deps, - }: DSProps, - onChange: (lastSelectChangeCount: number, name?: string) => void, - name?: string, - ) { - this.name = name; + constructor({ + store, + strictness, + mode, + makeCacheKey = mode.mode === 'simple' ? defaultMakeCacheKey : defaultMakeCacheKeyForInnerSelector, + equalityFn = deepEqual, + onlyReRenderWhen, + deps, + }: DSProps) { this.store = store; this.strictness = strictness; this.mode = mode; @@ -200,22 +76,6 @@ class DelayedSelectorProto { this.equalityFn = equalityFn; this.onlyReRenderWhen = onlyReRenderWhen; this.deps = deps; - this.onChange = onChange; - } - - public unsubscribe() { - if (this.unsubscribeMethod) { - this.unsubscribeMethod(); - this.unsubscribeMethod = null; - } - } - - public getSelector() { - return ((...args: unknown[]) => this.selector(...args)) as DSReturn; - } - - public setChangeCount(i: number) { - this.changeCount = i; } public checkDeps(newProps: DSProps) { @@ -238,13 +98,28 @@ class DelayedSelectorProto { this.onlyReRenderWhen = onlyReRenderWhen; this.deps = deps; - this.selectorsCalled = null; - this.unsubscribe(); - this.onChange(this.lastSelectChangeCount); + this.updateSelector(); + } + } + + protected abstract onUpdateSelector(): void; + protected onCallSelector() {} + + private updateSelector() { + this.selectorsCalled = null; + this.unsubscribeFromStore(); + this.selectorFunc = ((...args: unknown[]) => this.selector(...args)) as DSReturn; + this.onUpdateSelector(); + } + + public unsubscribeFromStore() { + if (this.unsubscribeMethod) { + this.unsubscribeMethod(); + this.unsubscribeMethod = null; } } - private subscribe() { + private subscribeToStore() { if (this.store === ContextNotProvided) { return null; } @@ -274,14 +149,12 @@ class DelayedSelectorProto { } } if (changed) { - this.selectorsCalled = null; - this.unsubscribe(); - this.onChange(this.lastSelectChangeCount, this.name); + this.updateSelector(); } }); } - public selector(...args: unknown[]) { + private selector(...args: unknown[]) { if (this.store === ContextNotProvided) { if (this.strictness === SelectorStrictness.throwWhenNotProvided) { throw new Error('useDelayedSelector: store not provided'); @@ -289,7 +162,7 @@ class DelayedSelectorProto { return ContextNotProvided; } - this.lastSelectChangeCount = this.changeCount; + this.onCallSelector(); const cacheKey = this.makeCacheKey(args); const prev = this.selectorsCalled?.get(cacheKey); @@ -307,7 +180,7 @@ class DelayedSelectorProto { this.selectorsCalled = new ShallowArrayMap(); } if (!this.unsubscribeMethod) { - this.unsubscribeMethod = this.subscribe(); + this.unsubscribeMethod = this.subscribeToStore(); } const state = this.store.getState(); @@ -340,175 +213,88 @@ class DelayedSelectorProto { } } -type MDSProps = { - [name: string]: DSProps; -}; - -type MDSSTate

= { - changeCount: number; - delayedSelectors: { [name in keyof P]?: DelayedSelectorProto }; - snapshot: { [name in keyof P]: DSReturn }; - subscribe: (callback: () => void) => () => void; - getSnapshot: () => { [name in keyof P]: DSReturn }; - forceRerender: () => void; - hasDeps: (keyof P)[]; -}; - -function initMultiSelectorState

(props: P, state: MutableRefObject>): MDSSTate

{ - const subscribe = (callback: () => void) => { - state.current.forceRerender = callback; - return () => Object.values(state.current.delayedSelectors).forEach((ds) => ds?.unsubscribe()); - }; - - const getSnapshot = () => state.current.snapshot; +class SingleDelayedSelectorController extends BaseDelayedSelector { + private triggerRender: () => void; - const onChange = (lastSelectRenderCount: number, name?: string) => { - // Prevent multiple re-renders at the same time - state.current.snapshot[name as keyof P] = state.current.delayedSelectors[name as keyof P]!.getSelector(); - if (lastSelectRenderCount === state.current.changeCount) { - state.current.snapshot = { ...state.current.snapshot }; - state.current.forceRerender(); - } - state.current.changeCount += 1; - Object.values(state.current.delayedSelectors).forEach((ds) => ds.setChangeCount(state.current.changeCount)); + public getSnapshot = () => this.selectorFunc; + public subscribe = (callback: () => void) => { + this.triggerRender = callback; + return () => this.unsubscribeFromStore; }; - // const [delayedSelectors, snapshot, hasDeps] = makeDelayedSelectors(props, onChange); - - const initialSnapshot = {}; - const hasDeps: (keyof P)[] = []; - for (const name in props) { - const prop = props[name]; - !!prop.deps && hasDeps.push(name); - initialSnapshot[name as string] = (...args: unknown[]) => { - let delayedSelector = state.current.delayedSelectors[name]; - if (!delayedSelector) { - delayedSelector = new DelayedSelectorProto(prop, onChange, name) as DelayedSelectorProto; - state.current.delayedSelectors[name] = delayedSelector; - } - return delayedSelector.selector(...args); - }; + protected onUpdateSelector(): void { + this.triggerRender(); } - - return { - changeCount: 0, - delayedSelectors: {}, - snapshot: initialSnapshot as { [name in keyof P]: DSReturn }, - subscribe, - getSnapshot, - forceRerender: () => {}, - hasDeps, - }; } -export function useMultipleDelayedSelectors

(props: P) { - const state: MutableRefObject> = useRef( - initMultiSelectorState(props, { - get current() { - return state.current; - }, - }), - ); +class MultiDelayedSelector extends BaseDelayedSelector { + private changeCount = 0; + private lastSelectChangeCount = 0; + private onChange: (lastSelectChangeCount: number) => void; - // Check if any deps have changed - if (state.current.hasDeps.length) { - for (const name of state.current.hasDeps) { - state.current.delayedSelectors[name]?.checkDeps(props[name] as never); - } + constructor(props: DSProps, onChange: (lastSelectChangeCount: number) => void) { + super(props); + this.onChange = onChange; } - return useSyncExternalStore(state.current.subscribe, state.current.getSnapshot) as { - [name in keyof P]: DSReturn; - }; -} + public getSelectorFunc() { + return this.selectorFunc; + } -type DSSTate = { - delayedSelector?: DelayedSelectorProto; - snapshot: DSReturn; - subscribe: (callback: () => void) => () => void; - getSnapshot: () => DSReturn; - forceRerender: () => void; - hasDeps: boolean; -}; - -function initSelectorState(props: DSProps, state: MutableRefObject>): DSSTate { - const subscribe = (callback: () => void) => { - state.current.forceRerender = callback; - return () => state.current.delayedSelector?.unsubscribe(); - }; + public setChangeCount(count: number) { + this.changeCount = count; + } - const getSnapshot = () => state.current.snapshot; + protected onCallSelector(): void { + this.lastSelectChangeCount = this.changeCount; + } - const onChange = () => { - state.current.snapshot = state.current.delayedSelector!.getSelector(); - state.current.forceRerender(); - }; + protected onUpdateSelector(): void { + this.onChange(this.lastSelectChangeCount); + } +} - const initialSnapshot = (...args: unknown[]) => { - let delayedSelector = state.current.delayedSelector; - if (!delayedSelector) { - delayedSelector = new DelayedSelectorProto(props, onChange); - state.current.delayedSelector = delayedSelector; +class MultiDelayedSelectorController

{ + private changeCount = 0; + private controllers: MultiDelayedSelector[] = []; + private selectorFuncs: DSReturn[] = []; + private triggerRender: () => void; + + constructor(props: P) { + for (let i = 0; i < props.length; i++) { + const MDS = new MultiDelayedSelector(props[i], (lastSelectChangeCount: number) => + this.onUpdateSelector(i, lastSelectChangeCount), + ); + this.controllers.push(MDS); + this.selectorFuncs.push(MDS.getSelectorFunc()); } - return delayedSelector.selector(...args); - }; + } - const hasDeps = !!props.deps; + public getSnapshot = () => this.selectorFuncs as { [I in keyof P]: DSReturn }; - return { - snapshot: initialSnapshot as DSReturn, - subscribe, - getSnapshot, - forceRerender: () => {}, - hasDeps, + public subscribe = (callback: () => void) => { + this.triggerRender = callback; + return () => this.controllers.forEach((c) => c.unsubscribeFromStore()); }; -} -export function useDelayedSelector2(props: DSProps): DSReturn { - const state: MutableRefObject> = useRef( - initSelectorState(props, { - get current() { - return state.current; - }, - }), - ); - - // Check if any deps have changed - if (state.current.hasDeps) { - state.current.delayedSelector?.checkDeps(props); + public checkDeps(newProps: P) { + for (let i = 0; i < newProps.length; i++) { + this.controllers[i].checkDeps(newProps[i]); + } } - return useSyncExternalStore(state.current.subscribe, state.current.getSnapshot); + private onUpdateSelector(index: number, lastSelectChangeCount: number): void { + // Prevent multiple re-renders at the same time + this.selectorFuncs[index] = this.controllers[index].getSelectorFunc(); + if (lastSelectChangeCount === this.changeCount) { + this.selectorFuncs = [...this.selectorFuncs]; + this.triggerRender(); + } + this.changeCount += 1; + this.controllers.forEach((ds) => ds.setChangeCount(this.changeCount)); + } } -// function makeDelayedSelectors

(props: P, onChange: (lastSelectRenderCount: number) => void) { -// const delayedSelectors = {}; -// const selectors = {}; -// const hasDeps: (keyof P)[] = []; -// for (const name in props) { -// const prop = props[name]; -// !!prop.deps && hasDeps.push(name); -// const ds = new DelayedSelectorProto(prop, onChange, name); -// delayedSelectors[name as string] = ds; -// selectors[name as string] = ds.getSelector(); -// } -// return [delayedSelectors, selectors, hasDeps] as [ -// MDSSTate

['delayedSelectors'], -// MDSSTate

['snapshot'], -// (keyof P)[], -// ]; -// } -// -// function getFreshSelectors

(delayedSelectors: { -// [name in keyof P]?: DelayedSelectorProto; -// }) { -// const selectors = {}; -// for (const name in delayedSelectors) { -// selectors[name as string] = delayedSelectors[name]?.getSelector(); -// } -// return selectors as MDSSTate

['snapshot']; -// } - function arrayShallowEqual(a: unknown[], b?: unknown[]) { if (a.length !== b?.length) { return false; @@ -598,6 +384,8 @@ export interface DSProps { deps?: any[]; } +type MultiDSProps = DSProps[]; + export type DSReturn = ModeFromConf extends SimpleArgMode ? (...args: Parameters) => ReturnType> diff --git a/src/utils/layout/generator/GeneratorDataSources.tsx b/src/utils/layout/generator/GeneratorDataSources.tsx index 542780ac55..c2fb78e8e8 100644 --- a/src/utils/layout/generator/GeneratorDataSources.tsx +++ b/src/utils/layout/generator/GeneratorDataSources.tsx @@ -55,13 +55,14 @@ export const GeneratorData = { }; function useExpressionDataSources(): ExpressionDataSources { - const selectors = useMultipleDelayedSelectors({ - formDataSelector: FD.useDebouncedSelectorProto(), - formDataRowsSelector: FD.useDebouncedRowsSelectorProto(), - attachmentsSelector: NodesInternal.useAttachmentsSelectorProto(), - optionsSelector: NodesInternal.useNodeOptionsSelectorProto(), - nodeDataSelector: NodesInternal.useNodeDataSelectorProto(), - }); + const [formDataSelector, formDataRowsSelector, attachmentsSelector, optionsSelector, nodeDataSelector] = + useMultipleDelayedSelectors( + FD.useDebouncedSelectorProto(), + FD.useDebouncedRowsSelectorProto(), + NodesInternal.useAttachmentsSelectorProto(), + NodesInternal.useNodeOptionsSelectorProto(), + NodesInternal.useNodeDataSelectorProto(), + ); const process = useLaxProcessData(); const applicationSettings = useApplicationSettings(); @@ -74,21 +75,21 @@ function useExpressionDataSources(): ExpressionDataSources { const isHiddenSelector = Hidden.useIsHiddenSelector(); const nodeTraversal = useInnerNodeTraversalSelector(hooks.useNodes()); - const transposeSelector = useInnerDataModelBindingTranspose(selectors.nodeDataSelector); - const nodeFormDataSelector = useInnerNodeFormDataSelector(selectors.nodeDataSelector, selectors.formDataSelector); + const transposeSelector = useInnerDataModelBindingTranspose(nodeDataSelector); + const nodeFormDataSelector = useInnerNodeFormDataSelector(nodeDataSelector, formDataSelector); const langToolsSelector = useInnerLanguageWithForcedNodeSelector( hooks.useDefaultDataType(), dataModelNames, - selectors.formDataSelector, - selectors.nodeDataSelector, + formDataSelector, + nodeDataSelector, ); return useShallowObjectMemo({ - formDataSelector: selectors.formDataSelector, - formDataRowsSelector: selectors.formDataRowsSelector, - attachmentsSelector: selectors.attachmentsSelector, - optionsSelector: selectors.optionsSelector, - nodeDataSelector: selectors.nodeDataSelector, + formDataSelector, + formDataRowsSelector, + attachmentsSelector, + optionsSelector, + nodeDataSelector, process, applicationSettings, instanceDataSources, @@ -105,26 +106,33 @@ function useExpressionDataSources(): ExpressionDataSources { } function useValidationDataSources(): ValidationDataSources { - const selectors = useMultipleDelayedSelectors({ - formDataSelector: FD.useDebouncedSelectorProto(), - invalidDataSelector: FD.useInvalidDebouncedSelectorProto(), - attachmentsSelector: NodesInternal.useAttachmentsSelectorProto(), - nodeDataSelector: NodesInternal.useNodeDataSelectorProto(), - dataElementsSelector: useLaxDataElementsSelectorProto(), - dataElementHasErrorsSelector: Validation.useDataElementHasErrorsSelectorProto(), - }); + const [ + formDataSelector, + invalidDataSelector, + attachmentsSelector, + nodeDataSelector, + dataElementsSelector, + dataElementHasErrorsSelector, + ] = useMultipleDelayedSelectors( + FD.useDebouncedSelectorProto(), + FD.useInvalidDebouncedSelectorProto(), + NodesInternal.useAttachmentsSelectorProto(), + NodesInternal.useNodeDataSelectorProto(), + useLaxDataElementsSelectorProto(), + Validation.useDataElementHasErrorsSelectorProto(), + ); const currentLanguage = useCurrentLanguage(); const applicationMetadata = useApplicationMetadata(); const layoutSets = useLayoutSets(); return useShallowObjectMemo({ - formDataSelector: selectors.formDataSelector, - invalidDataSelector: selectors.invalidDataSelector, - attachmentsSelector: selectors.attachmentsSelector, - nodeDataSelector: selectors.nodeDataSelector, - dataElementsSelector: selectors.dataElementsSelector, - dataElementHasErrorsSelector: selectors.dataElementHasErrorsSelector, + formDataSelector, + invalidDataSelector, + attachmentsSelector, + nodeDataSelector, + dataElementsSelector, + dataElementHasErrorsSelector, currentLanguage, applicationMetadata, layoutSets, From baa16e21c251878d7574f4847ced5c41144bb4be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rnar=20=C3=98sttveit?= Date: Wed, 13 Nov 2024 14:57:27 +0100 Subject: [PATCH 08/25] gather all selectors in expressiondatasrouces --- src/core/contexts/zustandContext.tsx | 4 ++ src/hooks/delayedSelectors.ts | 5 +- src/utils/layout/NodesContext.tsx | 50 ++++++++++++++++--- .../layout/generator/GeneratorDataSources.tsx | 28 +++++++---- src/utils/layout/useNodeTraversal.ts | 17 ++++--- 5 files changed, 80 insertions(+), 24 deletions(-) diff --git a/src/core/contexts/zustandContext.tsx b/src/core/contexts/zustandContext.tsx index 98b73b3086..148a030422 100644 --- a/src/core/contexts/zustandContext.tsx +++ b/src/core/contexts/zustandContext.tsx @@ -167,18 +167,22 @@ export function createZustandContext, Type = Extrac const useDSProto = >( mode: Mode, + deps?: unknown[], ): DSProps> => ({ store: useCtx(), strictness: SelectorStrictness.throwWhenNotProvided, mode, + deps, }); const useLaxDSProto = >( mode: Mode, + deps?: unknown[], ): DSProps> => ({ store: useLaxCtx(), strictness: SelectorStrictness.returnWhenNotProvided, mode, + deps, }); return { diff --git a/src/hooks/delayedSelectors.ts b/src/hooks/delayedSelectors.ts index cb22ba0c9b..4da938a0b5 100644 --- a/src/hooks/delayedSelectors.ts +++ b/src/hooks/delayedSelectors.ts @@ -98,7 +98,10 @@ abstract class BaseDelayedSelector { this.onlyReRenderWhen = onlyReRenderWhen; this.deps = deps; - this.updateSelector(); + // No need to trigger re-render if no selectors have been called + if (this.selectorsCalled) { + this.updateSelector(); + } } } diff --git a/src/utils/layout/NodesContext.tsx b/src/utils/layout/NodesContext.tsx index fdee3c8fc0..c2c25e574d 100644 --- a/src/utils/layout/NodesContext.tsx +++ b/src/utils/layout/NodesContext.tsx @@ -53,7 +53,7 @@ import type { FDSaveFinished } from 'src/features/formData/FormDataWriteStateMac import type { OptionsStorePluginConfig } from 'src/features/options/OptionsStorePlugin'; import type { ValidationsProcessedLast } from 'src/features/validation'; import type { ValidationStorePluginConfig } from 'src/features/validation/ValidationStorePlugin'; -import type { DSReturn, InnerSelectorMode, OnlyReRenderWhen } from 'src/hooks/delayedSelectors'; +import type { DSProps, DSReturn, InnerSelectorMode, OnlyReRenderWhen } from 'src/hooks/delayedSelectors'; import type { WaitForState } from 'src/hooks/useWaitForState'; import type { CompExternal, CompTypes, ILayouts } from 'src/layout/layout'; import type { LayoutComponent } from 'src/layout/LayoutComponent'; @@ -959,11 +959,25 @@ export const Hidden = { }, useIsHiddenSelector() { const forcedVisibleByDevTools = GeneratorData.useIsForcedVisibleByDevTools(); - return Store.useDelayedSelector({ - mode: 'simple', - selector: (node: LayoutNode | LayoutPage, options?: IsHiddenOptions) => (state) => - isHidden(state, node, makeOptions(forcedVisibleByDevTools, options)), - }); + return Store.useDelayedSelector( + { + mode: 'simple', + selector: (node: LayoutNode | LayoutPage, options?: IsHiddenOptions) => (state) => + isHidden(state, node, makeOptions(forcedVisibleByDevTools, options)), + }, + [forcedVisibleByDevTools], + ); + }, + useIsHiddenSelectorProto() { + const forcedVisibleByDevTools = GeneratorData.useIsForcedVisibleByDevTools(); + return Store.useDelayedSelectorProto( + { + mode: 'simple', + selector: (node: LayoutNode | LayoutPage, options?: IsHiddenOptions) => (state) => + isHidden(state, node, makeOptions(forcedVisibleByDevTools, options)), + }, + [forcedVisibleByDevTools], + ); }, /** @@ -1036,6 +1050,30 @@ export const NodesInternal = { } satisfies InnerSelectorMode, }); }, + useDataSelectorForTraversalProto(): DSProps<{ + store: StoreApi | typeof ContextNotProvided; + strictness: SelectorStrictness.returnWhenNotProvided; + mode: InnerSelectorMode; + }> { + return { + store: Store.useLaxStore(), + strictness: SelectorStrictness.returnWhenNotProvided, + onlyReRenderWhen: ((state, lastValue, setNewValue) => { + if (state.readiness !== NodesReadiness.Ready) { + return false; + } + if (lastValue !== state.addRemoveCounter) { + setNewValue(state.addRemoveCounter); + return true; + } + return false; + }) satisfies OnlyReRenderWhen, + mode: { + mode: 'innerSelector', + makeArgs: (state) => [state], + } satisfies InnerSelectorMode, + }; + }, useIsReady() { const isReady = Store.useLaxSelector((s) => s.readiness === NodesReadiness.Ready && s.hiddenViaRulesRan); if (isReady === ContextNotProvided) { diff --git a/src/utils/layout/generator/GeneratorDataSources.tsx b/src/utils/layout/generator/GeneratorDataSources.tsx index c2fb78e8e8..e1a4cc903b 100644 --- a/src/utils/layout/generator/GeneratorDataSources.tsx +++ b/src/utils/layout/generator/GeneratorDataSources.tsx @@ -55,14 +55,23 @@ export const GeneratorData = { }; function useExpressionDataSources(): ExpressionDataSources { - const [formDataSelector, formDataRowsSelector, attachmentsSelector, optionsSelector, nodeDataSelector] = - useMultipleDelayedSelectors( - FD.useDebouncedSelectorProto(), - FD.useDebouncedRowsSelectorProto(), - NodesInternal.useAttachmentsSelectorProto(), - NodesInternal.useNodeOptionsSelectorProto(), - NodesInternal.useNodeDataSelectorProto(), - ); + const [ + formDataSelector, + formDataRowsSelector, + attachmentsSelector, + optionsSelector, + nodeDataSelector, + dataSelectorForTraversal, + isHiddenSelector, + ] = useMultipleDelayedSelectors( + FD.useDebouncedSelectorProto(), + FD.useDebouncedRowsSelectorProto(), + NodesInternal.useAttachmentsSelectorProto(), + NodesInternal.useNodeOptionsSelectorProto(), + NodesInternal.useNodeDataSelectorProto(), + NodesInternal.useDataSelectorForTraversalProto(), + Hidden.useIsHiddenSelectorProto(), + ); const process = useLaxProcessData(); const applicationSettings = useApplicationSettings(); @@ -73,8 +82,7 @@ function useExpressionDataSources(): ExpressionDataSources { const dataModelNames = hooks.useReadableDataTypes(); const externalApis = hooks.useExternalApis(); - const isHiddenSelector = Hidden.useIsHiddenSelector(); - const nodeTraversal = useInnerNodeTraversalSelector(hooks.useNodes()); + const nodeTraversal = useInnerNodeTraversalSelector(hooks.useNodes(), dataSelectorForTraversal); const transposeSelector = useInnerDataModelBindingTranspose(nodeDataSelector); const nodeFormDataSelector = useInnerNodeFormDataSelector(nodeDataSelector, formDataSelector); const langToolsSelector = useInnerLanguageWithForcedNodeSelector( diff --git a/src/utils/layout/useNodeTraversal.ts b/src/utils/layout/useNodeTraversal.ts index 27a6847268..271aee2b7b 100644 --- a/src/utils/layout/useNodeTraversal.ts +++ b/src/utils/layout/useNodeTraversal.ts @@ -310,15 +310,15 @@ function throwOrReturn(value: R, strictness: Strictness) { */ function useNodeTraversalSelectorProto(strictness: Strict) { const nodes = useNodesLax(); - return useInnerNodeTraversalSelectorProto(strictness, nodes); + const nodeDataSelectorForTraversal = NodesInternal.useDataSelectorForTraversal(); + return useInnerNodeTraversalSelectorProto(strictness, nodes, nodeDataSelectorForTraversal); } function useInnerNodeTraversalSelectorProto( strictness: Strict, nodes: ReturnType, + nodeDataSelectorForTraversal: ReturnType, ) { - const selectState = NodesInternal.useDataSelectorForTraversal(); - return useCallback( ( innerSelector: (traverser: NodeTraversalFromRoot) => InnerSelectorReturns, @@ -328,14 +328,14 @@ function useInnerNodeTraversalSelectorProto( return throwOrReturn(ContextNotProvided, strictness) as InnerSelectorReturns; } - const value = selectState( + const value = nodeDataSelectorForTraversal( (state) => innerSelector(new NodeTraversal(state, nodes, nodes)) as InnerSelectorReturns, [innerSelector.toString(), ...deps], ); return throwOrReturn(value, strictness) as InnerSelectorReturns; }, - [selectState, nodes, strictness], + [nodeDataSelectorForTraversal, nodes, strictness], ); } @@ -343,8 +343,11 @@ export function useNodeTraversalSelector() { return useNodeTraversalSelectorProto(Strictness.throwError); } -export function useInnerNodeTraversalSelector(nodes: ReturnType) { - return useInnerNodeTraversalSelectorProto(Strictness.throwError, nodes); +export function useInnerNodeTraversalSelector( + nodes: ReturnType, + nodeDataSelectorForTraversal: ReturnType, +) { + return useInnerNodeTraversalSelectorProto(Strictness.throwError, nodes, nodeDataSelectorForTraversal); } export type NodeTraversalSelector = ReturnType; From 6112e6405117a2521acb93d8590f1256360e32e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rnar=20=C3=98sttveit?= Date: Wed, 13 Nov 2024 17:19:13 +0100 Subject: [PATCH 09/25] clean up --- src/core/contexts/zustandContext.tsx | 8 ++-- .../attachments/AttachmentsStorePlugin.tsx | 39 +++++++---------- src/features/formData/FormDataWrite.tsx | 43 ++++++++----------- src/features/instance/InstanceContext.tsx | 12 +++--- src/features/options/OptionsStorePlugin.tsx | 25 +++++------ src/features/validation/validationContext.tsx | 42 ++++++++---------- src/utils/layout/NodesContext.tsx | 23 +++++----- .../layout/generator/GeneratorDataSources.tsx | 28 ++++++------ 8 files changed, 102 insertions(+), 118 deletions(-) diff --git a/src/core/contexts/zustandContext.tsx b/src/core/contexts/zustandContext.tsx index 148a030422..e65aee761e 100644 --- a/src/core/contexts/zustandContext.tsx +++ b/src/core/contexts/zustandContext.tsx @@ -165,7 +165,7 @@ export function createZustandContext, Type = Extrac deps, }); - const useDSProto = >( + const useDSProps = >( mode: Mode, deps?: unknown[], ): DSProps> => ({ @@ -175,7 +175,7 @@ export function createZustandContext, Type = Extrac deps, }); - const useLaxDSProto = >( + const useLaxDSProps = >( mode: Mode, deps?: unknown[], ): DSProps> => ({ @@ -195,8 +195,8 @@ export function createZustandContext, Type = Extrac useLaxSelector, useDelayedSelector: useDS, useLaxDelayedSelector: useLaxDS, - useDelayedSelectorProto: useDSProto, - useLaxDelayedSelectorProto: useLaxDSProto, + useDelayedSelectorProps: useDSProps, + useLaxDelayedSelectorProps: useLaxDSProps, useHasProvider, useStore: useCtx, useLaxStore: useLaxCtx, diff --git a/src/features/attachments/AttachmentsStorePlugin.tsx b/src/features/attachments/AttachmentsStorePlugin.tsx index 181561262f..2e81822aab 100644 --- a/src/features/attachments/AttachmentsStorePlugin.tsx +++ b/src/features/attachments/AttachmentsStorePlugin.tsx @@ -84,7 +84,7 @@ export interface AttachmentsStorePluginConfig { useAttachments: (node: FileUploaderNode) => IAttachment[]; useAttachmentsSelector: () => AttachmentsSelector; - useAttachmentsSelectorProto: () => DSProps; + useAttachmentsSelectorProps: () => DSProps; useWaitUntilUploaded: () => (node: FileUploaderNode, attachment: TemporaryAttachment) => Promise; useHasPendingAttachments: () => boolean; @@ -389,31 +389,13 @@ export class AttachmentsStorePlugin extends NodeDataPlugin (state) => { - const nodeData = state.nodeData[node.id]; - if (!nodeData) { - return emptyArray; - } - if (nodeData && 'attachments' in nodeData) { - return Object.values(nodeData.attachments).sort(sortAttachmentsByName); - } - return emptyArray; - }, + selector: attachmentSelector, }) satisfies AttachmentsSelector; }, - useAttachmentsSelectorProto() { - return store.useDelayedSelectorProto({ + useAttachmentsSelectorProps() { + return store.useDelayedSelectorProps({ mode: 'simple', - selector: (node: LayoutNode) => (state) => { - const nodeData = state.nodeData[node.id]; - if (!nodeData) { - return emptyArray; - } - if (nodeData && 'attachments' in nodeData) { - return Object.values(nodeData.attachments).sort(sortAttachmentsByName); - } - return emptyArray; - }, + selector: attachmentSelector, }); }, useWaitUntilUploaded() { @@ -513,6 +495,17 @@ function useAttachmentsUploadMutation() { return useMutation(options); } +const attachmentSelector = (node: LayoutNode) => (state: NodesContext) => { + const nodeData = state.nodeData[node.id]; + if (!nodeData) { + return emptyArray; + } + if (nodeData && 'attachments' in nodeData) { + return Object.values(nodeData.attachments).sort(sortAttachmentsByName); + } + return emptyArray; +}; + function useAttachmentsAddTagMutation() { const { doAttachmentAddTag } = useAppMutations(); const instanceId = useLaxInstanceId(); diff --git a/src/features/formData/FormDataWrite.tsx b/src/features/formData/FormDataWrite.tsx index 6f41efb50a..a30feda138 100644 --- a/src/features/formData/FormDataWrite.tsx +++ b/src/features/formData/FormDataWrite.tsx @@ -64,7 +64,7 @@ const { useLaxMemoSelector, useLaxDelayedSelector, useDelayedSelector, - useDelayedSelectorProto, + useDelayedSelectorProps, useLaxSelector, useLaxStore, useStore, @@ -549,6 +549,15 @@ const debouncedSelector = (reference: IDataModelReference) => (state: FormDataCo const invalidDebouncedSelector = (reference: IDataModelReference) => (state: FormDataContext) => dot.pick(reference.field, state.dataModels[reference.dataType].invalidDebouncedCurrentData); +const debouncedRowSelector = (reference: IDataModelReference) => (state: FormDataContext) => { + const rawRows = dot.pick(reference.field, state.dataModels[reference.dataType].debouncedCurrentData); + if (!Array.isArray(rawRows) || !rawRows.length) { + return emptyArray; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return rawRows.map((row: any, index: number) => ({ uuid: row[ALTINN_ROW_ID], index })); +}; + export const FD = { /** * Gives you a selector function that can be used to look up paths in the data model. This is similar to @@ -563,8 +572,8 @@ export const FD = { }); }, - useDebouncedSelectorProto() { - return useDelayedSelectorProto({ + useDebouncedSelectorProps() { + return useDelayedSelectorProps({ mode: 'simple', selector: debouncedSelector, }); @@ -578,30 +587,14 @@ export const FD = { useDebouncedRowsSelector(): FormDataRowsSelector { return useDelayedSelector({ mode: 'simple', - selector: (reference: IDataModelReference) => (state) => { - const rawRows = dot.pick(reference.field, state.dataModels[reference.dataType].debouncedCurrentData); - if (!Array.isArray(rawRows) || !rawRows.length) { - return emptyArray; - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return rawRows.map((row: any, index: number) => ({ uuid: row[ALTINN_ROW_ID], index })); - }, + selector: debouncedRowSelector, }); }, - useDebouncedRowsSelectorProto() { - return useDelayedSelectorProto({ + useDebouncedRowsSelectorProps() { + return useDelayedSelectorProps({ mode: 'simple', - selector: (reference: IDataModelReference) => (state) => { - const rawRows = dot.pick(reference.field, state.dataModels[reference.dataType].debouncedCurrentData); - if (!Array.isArray(rawRows) || !rawRows.length) { - return emptyArray; - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return rawRows.map((row: any, index: number) => ({ uuid: row[ALTINN_ROW_ID], index })); - }, + selector: debouncedRowSelector, }); }, @@ -615,8 +608,8 @@ export const FD = { }); }, - useInvalidDebouncedSelectorProto() { - return useDelayedSelectorProto({ + useInvalidDebouncedSelectorProps() { + return useDelayedSelectorProps({ mode: 'simple', selector: invalidDebouncedSelector, }); diff --git a/src/features/instance/InstanceContext.tsx b/src/features/instance/InstanceContext.tsx index 589dc0d0ec..c3991b834d 100644 --- a/src/features/instance/InstanceContext.tsx +++ b/src/features/instance/InstanceContext.tsx @@ -52,7 +52,7 @@ const { useHasProvider, useLaxStore, useLaxDelayedSelector, - useLaxDelayedSelectorProto, + useLaxDelayedSelectorProps, } = createZustandContext({ name: 'InstanceContext', required: true, @@ -242,15 +242,17 @@ export const useLaxInstanceDataElements = (dataType: string | undefined) => useLaxInstance((state) => state.data?.data.filter((d) => d.dataType === dataType)) ?? emptyArray; export type DataElementSelector = (selector: (data: IData[]) => U, deps: unknown[]) => U | typeof ContextNotProvided; +const dataElementsInnerSelector = (state: InstanceContext) => [state.data?.data ?? emptyArray]; + export const useLaxDataElementsSelector = (): DataElementSelector => useLaxDelayedSelector({ mode: 'innerSelector', - makeArgs: (state) => [state.data?.data ?? emptyArray], + makeArgs: dataElementsInnerSelector, }); -export const useLaxDataElementsSelectorProto = () => - useLaxDelayedSelectorProto({ +export const useLaxDataElementsSelectorProps = () => + useLaxDelayedSelectorProps({ mode: 'innerSelector', - makeArgs: (state) => [state.data?.data ?? emptyArray], + makeArgs: dataElementsInnerSelector, }); /** Like useLaxInstanceAllDataElements, but will never re-render when the data changes */ diff --git a/src/features/options/OptionsStorePlugin.tsx b/src/features/options/OptionsStorePlugin.tsx index 9bab98ab82..167ec66c38 100644 --- a/src/features/options/OptionsStorePlugin.tsx +++ b/src/features/options/OptionsStorePlugin.tsx @@ -3,7 +3,7 @@ import type { IOptionInternal } from 'src/features/options/castOptionsToStrings' import type { DSConfig, DSProps } from 'src/hooks/delayedSelectors'; import type { CompWithBehavior } from 'src/layout/layout'; import type { LayoutNode } from 'src/utils/layout/LayoutNode'; -import type { NodesStoreFull } from 'src/utils/layout/NodesContext'; +import type { NodesContext, NodesStoreFull } from 'src/utils/layout/NodesContext'; import type { NodeDataPluginSetState } from 'src/utils/layout/plugins/NodeDataPlugin'; import type { NodeData } from 'src/utils/layout/types'; @@ -17,7 +17,7 @@ export interface OptionsStorePluginConfig { extraHooks: { useNodeOptions: NodeOptionsSelector; useNodeOptionsSelector: () => NodeOptionsSelector; - useNodeOptionsSelectorProto: () => DSProps; + useNodeOptionsSelectorProps: () => DSProps; }; } @@ -48,19 +48,20 @@ export class OptionsStorePlugin extends NodeDataPlugin useNodeOptionsSelector: () => store.useDelayedSelector({ mode: 'simple', - selector: (node: LayoutNode> | undefined) => (state) => { - const store = node ? state.nodeData[node.id] : undefined; - return { isFetching: nodeDataToIsFetching(store), options: nodeDataToOptions(store) }; - }, + selector: nodeOptionsSelector, }), - useNodeOptionsSelectorProto: () => - store.useDelayedSelectorProto({ + + useNodeOptionsSelectorProps: () => + store.useDelayedSelectorProps({ mode: 'simple', - selector: (node: LayoutNode> | undefined) => (state) => { - const store = node ? state.nodeData[node.id] : undefined; - return { isFetching: nodeDataToIsFetching(store), options: nodeDataToOptions(store) }; - }, + selector: nodeOptionsSelector, }), }; } } + +const nodeOptionsSelector = + (node: LayoutNode> | undefined) => (state: NodesContext) => { + const store = node ? state.nodeData[node.id] : undefined; + return { isFetching: nodeDataToIsFetching(store), options: nodeDataToOptions(store) }; + }; diff --git a/src/features/validation/validationContext.tsx b/src/features/validation/validationContext.tsx index 18c66e5a17..bf0ae9388b 100644 --- a/src/features/validation/validationContext.tsx +++ b/src/features/validation/validationContext.tsx @@ -150,7 +150,7 @@ const { useStore, useLaxSelectorAsRef, useDelayedSelector, - useDelayedSelectorProto, + useDelayedSelectorProps, } = createZustandContext({ name: 'Validation', required: true, @@ -316,6 +316,18 @@ function useDS(outerSelector: (state: ValidationContext) => U) { }); } +const dataElementHasErrorsSelector = (dataElementId: string) => (state: ValidationContext) => { + const dataElementValidations = state.state.dataModels[dataElementId]; + for (const fieldValidations of Object.values(dataElementValidations ?? {})) { + for (const validation of fieldValidations) { + if (validation.severity === 'error') { + return true; + } + } + } + return false; +}; + export type ValidationSelector = ReturnType; export type ValidationDataModelSelector = ReturnType; export type DataElementHasErrorsSelector = ReturnType; @@ -330,33 +342,13 @@ export const Validation = { useDataElementHasErrorsSelector: () => useDelayedSelector({ mode: 'simple', - selector: (dataElementId: string) => (state) => { - const dataElementValidations = state.state.dataModels[dataElementId]; - for (const fieldValidations of Object.values(dataElementValidations ?? {})) { - for (const validation of fieldValidations) { - if (validation.severity === 'error') { - return true; - } - } - } - return false; - }, + selector: dataElementHasErrorsSelector, }), - useDataElementHasErrorsSelectorProto: () => - useDelayedSelectorProto({ + useDataElementHasErrorsSelectorProps: () => + useDelayedSelectorProps({ mode: 'simple', - selector: (dataElementId: string) => (state) => { - const dataElementValidations = state.state.dataModels[dataElementId]; - for (const fieldValidations of Object.values(dataElementValidations ?? {})) { - for (const validation of fieldValidations) { - if (validation.severity === 'error') { - return true; - } - } - } - return false; - }, + selector: dataElementHasErrorsSelector, }), useShowAllBackendErrors: () => useSelector((state) => state.showAllBackendErrors), diff --git a/src/utils/layout/NodesContext.tsx b/src/utils/layout/NodesContext.tsx index c2c25e574d..9dd685143a 100644 --- a/src/utils/layout/NodesContext.tsx +++ b/src/utils/layout/NodesContext.tsx @@ -944,11 +944,14 @@ export const Hidden = { }, useIsHiddenPageSelector() { const forcedVisibleByDevTools = GeneratorData.useIsForcedVisibleByDevTools(); - return Store.useDelayedSelector({ - mode: 'simple', - selector: (page: LayoutPage | string) => (state) => - isHiddenPage(state, page, makeOptions(forcedVisibleByDevTools)), - }); + return Store.useDelayedSelector( + { + mode: 'simple', + selector: (page: LayoutPage | string) => (state) => + isHiddenPage(state, page, makeOptions(forcedVisibleByDevTools)), + }, + [forcedVisibleByDevTools], + ); }, useHiddenPages(): Set { const forcedVisibleByDevTools = GeneratorData.useIsForcedVisibleByDevTools(); @@ -968,9 +971,9 @@ export const Hidden = { [forcedVisibleByDevTools], ); }, - useIsHiddenSelectorProto() { + useIsHiddenSelectorProps() { const forcedVisibleByDevTools = GeneratorData.useIsForcedVisibleByDevTools(); - return Store.useDelayedSelectorProto( + return Store.useDelayedSelectorProps( { mode: 'simple', selector: (node: LayoutNode | LayoutPage, options?: IsHiddenOptions) => (state) => @@ -1050,7 +1053,7 @@ export const NodesInternal = { } satisfies InnerSelectorMode, }); }, - useDataSelectorForTraversalProto(): DSProps<{ + useDataSelectorForTraversalProps(): DSProps<{ store: StoreApi | typeof ContextNotProvided; strictness: SelectorStrictness.returnWhenNotProvided; mode: InnerSelectorMode; @@ -1223,8 +1226,8 @@ export const NodesInternal = { mode: 'innerSelector', makeArgs: (state) => [((node) => selectNodeData(node, state)) satisfies NodePicker], }), - useNodeDataSelectorProto: () => - Store.useDelayedSelectorProto({ + useNodeDataSelectorProps: () => + Store.useDelayedSelectorProps({ mode: 'innerSelector', makeArgs: (state) => [((node) => selectNodeData(node, state)) satisfies NodePicker], }), diff --git a/src/utils/layout/generator/GeneratorDataSources.tsx b/src/utils/layout/generator/GeneratorDataSources.tsx index e1a4cc903b..36c869cac2 100644 --- a/src/utils/layout/generator/GeneratorDataSources.tsx +++ b/src/utils/layout/generator/GeneratorDataSources.tsx @@ -7,7 +7,7 @@ import { useExternalApis } from 'src/features/externalApi/useExternalApi'; import { useLayoutSets } from 'src/features/form/layoutSets/LayoutSetsProvider'; import { useCurrentLayoutSet } from 'src/features/form/layoutSets/useCurrentLayoutSet'; import { FD } from 'src/features/formData/FormDataWrite'; -import { useLaxDataElementsSelectorProto, useLaxInstanceDataSources } from 'src/features/instance/InstanceContext'; +import { useLaxDataElementsSelectorProps, useLaxInstanceDataSources } from 'src/features/instance/InstanceContext'; import { useLaxProcessData } from 'src/features/instance/ProcessContext'; import { useCurrentLanguage } from 'src/features/language/LanguageProvider'; import { useInnerLanguageWithForcedNodeSelector } from 'src/features/language/useLanguage'; @@ -64,13 +64,13 @@ function useExpressionDataSources(): ExpressionDataSources { dataSelectorForTraversal, isHiddenSelector, ] = useMultipleDelayedSelectors( - FD.useDebouncedSelectorProto(), - FD.useDebouncedRowsSelectorProto(), - NodesInternal.useAttachmentsSelectorProto(), - NodesInternal.useNodeOptionsSelectorProto(), - NodesInternal.useNodeDataSelectorProto(), - NodesInternal.useDataSelectorForTraversalProto(), - Hidden.useIsHiddenSelectorProto(), + FD.useDebouncedSelectorProps(), + FD.useDebouncedRowsSelectorProps(), + NodesInternal.useAttachmentsSelectorProps(), + NodesInternal.useNodeOptionsSelectorProps(), + NodesInternal.useNodeDataSelectorProps(), + NodesInternal.useDataSelectorForTraversalProps(), + Hidden.useIsHiddenSelectorProps(), ); const process = useLaxProcessData(); @@ -122,12 +122,12 @@ function useValidationDataSources(): ValidationDataSources { dataElementsSelector, dataElementHasErrorsSelector, ] = useMultipleDelayedSelectors( - FD.useDebouncedSelectorProto(), - FD.useInvalidDebouncedSelectorProto(), - NodesInternal.useAttachmentsSelectorProto(), - NodesInternal.useNodeDataSelectorProto(), - useLaxDataElementsSelectorProto(), - Validation.useDataElementHasErrorsSelectorProto(), + FD.useDebouncedSelectorProps(), + FD.useInvalidDebouncedSelectorProps(), + NodesInternal.useAttachmentsSelectorProps(), + NodesInternal.useNodeDataSelectorProps(), + useLaxDataElementsSelectorProps(), + Validation.useDataElementHasErrorsSelectorProps(), ); const currentLanguage = useCurrentLanguage(); From e40d79b509818c827008ee2899c9e56d233dad5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rnar=20=C3=98sttveit?= Date: Wed, 13 Nov 2024 17:45:29 +0100 Subject: [PATCH 10/25] fix expression tests --- src/features/expressions/shared-functions.test.tsx | 4 +++- src/utils/layout/generator/debug.ts | 4 ++-- src/utils/layout/generator/useEvalExpression.ts | 11 ++++++----- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/features/expressions/shared-functions.test.tsx b/src/features/expressions/shared-functions.test.tsx index b793c00499..e9b6606ff6 100644 --- a/src/features/expressions/shared-functions.test.tsx +++ b/src/features/expressions/shared-functions.test.tsx @@ -15,6 +15,7 @@ import { useExternalApis } from 'src/features/externalApi/useExternalApi'; import { fetchApplicationMetadata, fetchProcessState } from 'src/queries/queries'; import { renderWithNode } from 'src/test/renderWithProviders'; import { useEvalExpression } from 'src/utils/layout/generator/useEvalExpression'; +import { useExpressionDataSources } from 'src/utils/layout/useExpressionDataSources'; import type { SharedTestFunctionContext } from 'src/features/expressions/shared'; import type { ExprValToActualOrExpr } from 'src/features/expressions/types'; import type { ExternalApisResult } from 'src/features/externalApi/useExternalApi'; @@ -25,7 +26,8 @@ import type { LayoutNode } from 'src/utils/layout/LayoutNode'; jest.mock('src/features/externalApi/useExternalApi'); function ExpressionRunner({ node, expression }: { node: LayoutNode; expression: ExprValToActualOrExpr }) { - const result = useEvalExpression(ExprVal.Any, node, expression, null); + const dataSources = useExpressionDataSources(); + const result = useEvalExpression(ExprVal.Any, node, expression, null, dataSources); return ( <>

{JSON.stringify(result)}
diff --git a/src/utils/layout/generator/debug.ts b/src/utils/layout/generator/debug.ts index b57e234a53..115f06b783 100644 --- a/src/utils/layout/generator/debug.ts +++ b/src/utils/layout/generator/debug.ts @@ -3,8 +3,8 @@ export const GeneratorDebug = { displayState: debugAll, displayReadiness: debugAll, logReadiness: debugAll, - logStages: debugAll || true, - logCommits: debugAll || true, + logStages: debugAll, + logCommits: debugAll, }; export const generatorLog = (logType: keyof typeof GeneratorDebug, ...messages: unknown[]) => { diff --git a/src/utils/layout/generator/useEvalExpression.ts b/src/utils/layout/generator/useEvalExpression.ts index 73b18d9b36..4b1652cd26 100644 --- a/src/utils/layout/generator/useEvalExpression.ts +++ b/src/utils/layout/generator/useEvalExpression.ts @@ -7,6 +7,7 @@ import { GeneratorStages } from 'src/utils/layout/generator/GeneratorStages'; import { LayoutPage } from 'src/utils/layout/LayoutPage'; import type { ExprConfig, ExprVal, ExprValToActual, ExprValToActualOrExpr } from 'src/features/expressions/types'; import type { LayoutNode } from 'src/utils/layout/LayoutNode'; +import type { ExpressionDataSources } from 'src/utils/layout/useExpressionDataSources'; export function useEvalExpressionInGenerator( type: V, @@ -14,8 +15,9 @@ export function useEvalExpressionInGenerator( expr: ExprValToActualOrExpr | undefined, defaultValue: ExprValToActual, ) { + const dataSources = GeneratorData.useExpressionDataSources(); const enabled = GeneratorStages.useIsDoneAddingNodes(); - return useEvalExpression(type, node, expr, defaultValue, enabled); + return useEvalExpression(type, node, expr, defaultValue, dataSources, enabled); } /** @@ -41,10 +43,9 @@ export function useEvalExpression( node: LayoutNode | LayoutPage, expr: ExprValToActualOrExpr | undefined, defaultValue: ExprValToActual, + dataSources: ExpressionDataSources, enabled = true, ) { - const allDataSources = GeneratorData.useExpressionDataSources(); - return useMemo(() => { if (!enabled) { return defaultValue; @@ -61,6 +62,6 @@ export function useEvalExpression( defaultValue, }; - return evalExpr(expr, node, allDataSources, { config, errorIntroText }); - }, [enabled, allDataSources, defaultValue, expr, node, type]); + return evalExpr(expr, node, dataSources, { config, errorIntroText }); + }, [enabled, dataSources, defaultValue, expr, node, type]); } From fbd0b4dbb9b72546f4b9a99a5ca6c71c54883300 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rnar=20=C3=98sttveit?= Date: Thu, 14 Nov 2024 15:22:24 +0100 Subject: [PATCH 11/25] fix memory leak (forgot to call unsubscribe) --- src/hooks/delayedSelectors.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/delayedSelectors.ts b/src/hooks/delayedSelectors.ts index 4da938a0b5..2e6a11c0aa 100644 --- a/src/hooks/delayedSelectors.ts +++ b/src/hooks/delayedSelectors.ts @@ -222,7 +222,7 @@ class SingleDelayedSelectorController extends BaseDelayedSel public getSnapshot = () => this.selectorFunc; public subscribe = (callback: () => void) => { this.triggerRender = callback; - return () => this.unsubscribeFromStore; + return () => this.unsubscribeFromStore(); }; protected onUpdateSelector(): void { From 16e6dd13655a96ac501b8cbcd8a4442d7130edbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rnar=20=C3=98sttveit?= Date: Thu, 14 Nov 2024 16:10:03 +0100 Subject: [PATCH 12/25] quickfix useNodes is undefined --- src/utils/layout/NodesContext.tsx | 15 +++++++++++---- .../layout/generator/GeneratorDataSources.tsx | 5 ++--- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/utils/layout/NodesContext.tsx b/src/utils/layout/NodesContext.tsx index 9dd685143a..53366bd4f2 100644 --- a/src/utils/layout/NodesContext.tsx +++ b/src/utils/layout/NodesContext.tsx @@ -424,9 +424,9 @@ export type NodesStoreFull = typeof Store; * data. */ const WhenReady = { - useSelector: (selector: (state: NodesContext) => T): T | undefined => { + useSelector: (selector: (state: NodesContext) => T, or?: unknown[]): T | undefined => { const prevValue = useRef(NeverInitialized); - return Store.useSelector((state) => whenReadySelector(state, selector, prevValue)); + return Store.useSelector((state) => whenReadySelector(state, selector, prevValue, or)); }, useMemoSelector: (selector: (state: NodesContext) => T): T | undefined => { const prevValue = useRef(NeverInitialized); @@ -447,8 +447,13 @@ function whenReadySelector( state: NodesContext, selector: (state: NodesContext) => T, prevValue: MutableRefObject, + or?: unknown[], ) { - if (state.readiness === NodesReadiness.Ready || prevValue.current === NeverInitialized) { + if ( + state.readiness === NodesReadiness.Ready || + prevValue.current === NeverInitialized || + or?.includes(prevValue.current) + ) { const value = selector(state); prevValue.current = value; return value; @@ -834,7 +839,9 @@ export const useGetPage = (pageId: string | undefined) => return state.nodes.findLayout(new TraversalTask(state, state.nodes, undefined, undefined), pageId); }); -export const useNodes = () => WhenReady.useSelector((s) => s.nodes!); +export const useNodes = () => WhenReady.useSelector((s) => s.nodes); +// This will rerender until it is no longer undefined, at that point it only rerenders when ready +export const useNodesWhenReadyOrInit = () => WhenReady.useSelector((s) => s.nodes, [undefined]); export const useNodesWhenNotReady = () => Store.useSelector((s) => s.nodes); export const useNodesLax = () => WhenReady.useLaxSelector((s) => s.nodes); diff --git a/src/utils/layout/generator/GeneratorDataSources.tsx b/src/utils/layout/generator/GeneratorDataSources.tsx index 36c869cac2..3b2def701c 100644 --- a/src/utils/layout/generator/GeneratorDataSources.tsx +++ b/src/utils/layout/generator/GeneratorDataSources.tsx @@ -15,7 +15,7 @@ import { Validation } from 'src/features/validation/validationContext'; import { useMultipleDelayedSelectors } from 'src/hooks/delayedSelectors'; import { useShallowObjectMemo } from 'src/hooks/useShallowObjectMemo'; import { useCommitWhenFinished } from 'src/utils/layout/generator/CommitQueue'; -import { Hidden, NodesInternal, useNodes } from 'src/utils/layout/NodesContext'; +import { Hidden, NodesInternal, useNodesWhenReadyOrInit } from 'src/utils/layout/NodesContext'; import { useInnerDataModelBindingTranspose } from 'src/utils/layout/useDataModelBindingTranspose'; import { useInnerNodeFormDataSelector } from 'src/utils/layout/useNodeItem'; import { useInnerNodeTraversalSelector } from 'src/utils/layout/useNodeTraversal'; @@ -28,7 +28,7 @@ const { Provider, hooks } = createHookContext({ useDefaultDataType: () => DataModels.useDefaultDataType(), useReadableDataTypes: () => DataModels.useReadableDataTypes(), useExternalApis: () => useExternalApis(useApplicationMetadata().externalApiIds ?? []), - useNodes: () => useNodes(), + useNodes: () => useNodesWhenReadyOrInit(), useIsForcedVisibleByDevTools: () => { const devToolsIsOpen = useDevToolsStore((state) => state.isOpen); const devToolsHiddenComponents = useDevToolsStore((state) => state.hiddenComponents); @@ -81,7 +81,6 @@ function useExpressionDataSources(): ExpressionDataSources { const currentLayoutSet = hooks.useCurrentLayoutSet() ?? null; const dataModelNames = hooks.useReadableDataTypes(); const externalApis = hooks.useExternalApis(); - const nodeTraversal = useInnerNodeTraversalSelector(hooks.useNodes(), dataSelectorForTraversal); const transposeSelector = useInnerDataModelBindingTranspose(nodeDataSelector); const nodeFormDataSelector = useInnerNodeFormDataSelector(nodeDataSelector, formDataSelector); From bf34404edbe8aea583e4bf57c490b89950ea3bdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rnar=20=C3=98sttveit?= Date: Fri, 15 Nov 2024 10:52:15 +0100 Subject: [PATCH 13/25] fix stale initial data when changing task --- src/features/datamodel/DataModelsProvider.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/features/datamodel/DataModelsProvider.tsx b/src/features/datamodel/DataModelsProvider.tsx index 2056e10825..2c74dee38c 100644 --- a/src/features/datamodel/DataModelsProvider.tsx +++ b/src/features/datamodel/DataModelsProvider.tsx @@ -67,14 +67,20 @@ function initialCreateStore() { writableDataTypes: null, initialData: {}, dataElementIds: {}, - initialValidations: null, schemas: {}, schemaLookup: {}, expressionValidationConfigs: {}, error: null, setDataTypes: (allDataTypes, writableDataTypes, defaultDataType, layoutSetId) => { - set(() => ({ allDataTypes, writableDataTypes, defaultDataType, layoutSetId })); + set(() => ({ + allDataTypes, + writableDataTypes, + defaultDataType, + layoutSetId, + initialData: {}, + dataElementIds: {}, + })); }, setInitialData: (dataType, initialData, dataElementId) => { set((state) => ({ From f0f01807577879899819c438f19a6de8d7259701 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rnar=20=C3=98sttveit?= Date: Fri, 15 Nov 2024 13:47:40 +0100 Subject: [PATCH 14/25] fix queued requests not getting committed after adding or removing nodes (hopefully) --- src/utils/layout/generator/CommitQueue.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/utils/layout/generator/CommitQueue.tsx b/src/utils/layout/generator/CommitQueue.tsx index f814d3ff91..203d125e1a 100644 --- a/src/utils/layout/generator/CommitQueue.tsx +++ b/src/utils/layout/generator/CommitQueue.tsx @@ -55,23 +55,22 @@ export function useCommit() { return useCallback(() => { const toCommit = registry.current.toCommit; + let changes = false; + if (toCommit.addNodes.length) { generatorLog('logCommits', 'Committing', toCommit.addNodes.length, 'addNodes requests'); addNodes(toCommit.addNodes); toCommit.addNodes.length = 0; // This truncates the array, but keeps the reference - updateCommitsPendingInBody(toCommit); - return true; + changes = true; } if (toCommit.removeNodes.length) { generatorLog('logCommits', 'Committing', toCommit.removeNodes.length, 'removeNodes requests'); removeNodes(toCommit.removeNodes); toCommit.removeNodes.length = 0; - updateCommitsPendingInBody(toCommit); - return true; + changes = true; } - let changes = false; if (toCommit.setNodeProps.length) { generatorLog('logCommits', 'Committing', toCommit.setNodeProps.length, 'setNodeProps requests:', () => { const counts = {}; From b28a697eb1606492b87e7890f3a5b08c0203b469 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rnar=20=C3=98sttveit?= Date: Fri, 15 Nov 2024 14:17:45 +0100 Subject: [PATCH 15/25] fix stale initial data when changing layout in cypress --- test/e2e/support/custom.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/e2e/support/custom.ts b/test/e2e/support/custom.ts index 1e0e1682c1..22977f51b1 100644 --- a/test/e2e/support/custom.ts +++ b/test/e2e/support/custom.ts @@ -478,6 +478,7 @@ Cypress.Commands.add('interceptLayout', (taskName, mutator, wholeLayoutMutator, Cypress.Commands.add('changeLayout', (mutator, wholeLayoutMutator) => { cy.log('Changing current layout'); + cy.waitUntilSaved(); cy.window().then((win) => { const activeData = win.queryClient.getQueryCache().findAll({ type: 'active' }); for (const query of activeData) { From 38559030d04d68306a95f4ba100ae3807e2925ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rnar=20=C3=98sttveit?= Date: Fri, 15 Nov 2024 14:59:49 +0100 Subject: [PATCH 16/25] fix stale validations when changing layouts in cypress, no clue why this worked --- .../validation/backendValidation/backendValidationQuery.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/features/validation/backendValidation/backendValidationQuery.ts b/src/features/validation/backendValidation/backendValidationQuery.ts index 97c297a62a..85f08b6c6a 100644 --- a/src/features/validation/backendValidation/backendValidationQuery.ts +++ b/src/features/validation/backendValidation/backendValidationQuery.ts @@ -148,6 +148,7 @@ export function useBackendValidationQuery(enabled: boolean) { queryKey, queryFn, enabled, + staleTime: 0, gcTime: 0, }); From 35287bb6dddaef6ee51226edf70775e0aba5d033 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rnar=20=C3=98sttveit?= Date: Fri, 15 Nov 2024 15:11:28 +0100 Subject: [PATCH 17/25] use shared hook for remove node --- src/utils/layout/generator/CommitQueue.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/layout/generator/CommitQueue.tsx b/src/utils/layout/generator/CommitQueue.tsx index 203d125e1a..def25914bf 100644 --- a/src/utils/layout/generator/CommitQueue.tsx +++ b/src/utils/layout/generator/CommitQueue.tsx @@ -187,7 +187,7 @@ function useRemoveNode(request: Omit) { const registry = GeneratorInternal.useRegistry(); const toCommit = registry.current.toCommit; const ref = useAsRef(request); - const commit = useCommitWhenFinished(); + const commit = GeneratorData.useCommitWhenFinished(); useEffect(() => { const reg = registry.current; From 3c89bde2531d34543175989166e5b66883cb0dba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rnar=20=C3=98sttveit?= <47412359+bjosttveit@users.noreply.github.com> Date: Fri, 15 Nov 2024 16:35:52 +0100 Subject: [PATCH 18/25] Update GeneratorDataSources.tsx Co-authored-by: Ole Martin Handeland --- src/utils/layout/generator/GeneratorDataSources.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/utils/layout/generator/GeneratorDataSources.tsx b/src/utils/layout/generator/GeneratorDataSources.tsx index 3b2def701c..d1f9c28be0 100644 --- a/src/utils/layout/generator/GeneratorDataSources.tsx +++ b/src/utils/layout/generator/GeneratorDataSources.tsx @@ -30,9 +30,7 @@ const { Provider, hooks } = createHookContext({ useExternalApis: () => useExternalApis(useApplicationMetadata().externalApiIds ?? []), useNodes: () => useNodesWhenReadyOrInit(), useIsForcedVisibleByDevTools: () => { - const devToolsIsOpen = useDevToolsStore((state) => state.isOpen); - const devToolsHiddenComponents = useDevToolsStore((state) => state.hiddenComponents); - return devToolsIsOpen && devToolsHiddenComponents !== 'hide'; + return useDevToolsStore((state) => state.isOpen && state.hiddenComponents !== 'hide'); }, useGetDataElementIdForDataType: () => DataModels.useGetDataElementIdForDataType(), From 260056137df812b33fe8b3c38c11998a394c9ed9 Mon Sep 17 00:00:00 2001 From: Ole Martin Handeland Date: Fri, 15 Nov 2024 17:34:26 +0100 Subject: [PATCH 19/25] Storing the nodes object (LayoutPages) outside the NodesContext zustand store, making it far more accessible (also removes the need for selecting it in a hookContext) --- .../validation/ValidationStorePlugin.tsx | 34 ++++--- src/utils/layout/NodesContext.tsx | 94 +++++++++---------- src/utils/layout/all.test.tsx | 4 +- .../layout/generator/GeneratorDataSources.tsx | 9 +- .../layout/generator/LayoutSetGenerator.tsx | 30 +----- src/utils/layout/useNodeTraversal.ts | 4 +- 6 files changed, 75 insertions(+), 100 deletions(-) diff --git a/src/features/validation/ValidationStorePlugin.tsx b/src/features/validation/ValidationStorePlugin.tsx index 9971964336..a8f087ea94 100644 --- a/src/features/validation/ValidationStorePlugin.tsx +++ b/src/features/validation/ValidationStorePlugin.tsx @@ -3,7 +3,7 @@ import { useCallback } from 'react'; import { ContextNotProvided } from 'src/core/contexts/context'; import { FrontendValidationSource, ValidationMask } from 'src/features/validation/index'; import { getInitialMaskFromNodeItem, selectValidations } from 'src/features/validation/utils'; -import { isHidden, nodesProduce } from 'src/utils/layout/NodesContext'; +import { isHidden, nodesProduce, useNodesLax } from 'src/utils/layout/NodesContext'; import { NodeDataPlugin } from 'src/utils/layout/plugins/NodeDataPlugin'; import { TraversalTask } from 'src/utils/layout/useNodeTraversal'; import type { @@ -151,10 +151,11 @@ export class ValidationStorePlugin extends NodeDataPlugin getValidations({ state, node, mask, severity, includeHidden }), }) satisfies ValidationsSelector, - useAllValidations: (mask, severity, includeHidden) => - store.useLaxMemoSelector((state) => { + useAllValidations: (mask, severity, includeHidden) => { + const nodes = useNodesLax(); + return store.useLaxMemoSelector((state) => { const out: NodeRefValidation[] = []; - for (const node of state.nodes?.allNodes() ?? []) { + for (const node of nodes?.allNodes() ?? []) { const validations = getValidations({ state, node, mask, severity, includeHidden }); for (const validation of validations) { out.push({ ...validation, nodeId: node.id }); @@ -162,9 +163,11 @@ export class ValidationStorePlugin extends NodeDataPlugin { const zustand = store.useLaxStore(); + const nodes = useNodesLax(); return useCallback( (mask, severity, includeHidden = false) => { if (zustand === ContextNotProvided) { @@ -177,7 +180,7 @@ export class ValidationStorePlugin extends NodeDataPlugin 0) { outNodes.push(node.id); @@ -186,16 +189,17 @@ export class ValidationStorePlugin extends NodeDataPlugin - store.useSelector((state) => { + usePageHasVisibleRequiredValidations: (pageKey) => { + const nodes = useNodesLax(); + return store.useSelector((state) => { if (!pageKey) { return false; } - for (const node of state.nodes?.allNodes() ?? []) { + for (const node of nodes?.allNodes() ?? []) { const nodeData = state.nodeData[node.id]; if (!nodeData || nodeData.pageKey !== pageKey) { continue; @@ -210,7 +214,8 @@ export class ValidationStorePlugin extends NodeDataPlugin void; addNodes: (requests: AddNodeRequest[]) => void; removeNodes: (request: RemoveNodeRequest[]) => void; setNodeProps: (requests: SetNodePropRequest[]) => void; @@ -194,7 +192,6 @@ export function createNodesDataStore({ registry, validationsProcessedLast }: Cre readiness: NodesReadiness.NotReady, addRemoveCounter: 0, hasErrors: false, - nodes: undefined, pagesData: { type: 'pages' as const, pages: {}, @@ -222,7 +219,6 @@ export function createNodesDataStore({ registry, validationsProcessedLast }: Cre return { hiddenViaRules: newState, hiddenViaRulesRan: true }; }), - setNodes: (nodes) => set({ nodes }), addNodes: (requests) => set((state) => { const nodeData = { ...state.nodeData }; @@ -484,9 +480,9 @@ export type NodesStoreFull = typeof Store; * data. */ const WhenReady = { - useSelector: (selector: (state: NodesContext) => T, or?: unknown[]): T | undefined => { + useSelector: (selector: (state: NodesContext) => T): T | undefined => { const prevValue = useRef(NeverInitialized); - return Store.useSelector((state) => whenReadySelector(state, selector, prevValue, or)); + return Store.useSelector((state) => whenReadySelector(state, selector, prevValue)); }, useMemoSelector: (selector: (state: NodesContext) => T): T | undefined => { const prevValue = useRef(NeverInitialized); @@ -507,13 +503,8 @@ function whenReadySelector( state: NodesContext, selector: (state: NodesContext) => T, prevValue: MutableRefObject, - or?: unknown[], ) { - if ( - state.readiness === NodesReadiness.Ready || - prevValue.current === NeverInitialized || - or?.includes(prevValue.current) - ) { + if (state.readiness === NodesReadiness.Ready || prevValue.current === NeverInitialized) { const value = selector(state); prevValue.current = value; return value; @@ -550,6 +541,15 @@ const Conditionally = { }, }; +const { + Provider: ProvideLayoutPages, + useCtx: useLayoutPages, + useLaxCtx: useLaxLayoutPages, +} = createContext({ + name: 'LayoutPages', + required: true, +}); + export const NodesProvider = ({ children }: React.PropsWithChildren) => { const registry = useRegistry(); const processedLast = Validation.useProcessedLastRef(); @@ -568,9 +568,7 @@ export const NodesProvider = ({ children }: React.PropsWithChildren) => { {window.Cypress && } - - - + @@ -588,10 +586,15 @@ function ProvideGlobalContext({ children, registry }: PropsWithChildren<{ regist const markNotReady = NodesInternal.useMarkNotReady(); const reset = Store.useSelector((s) => s.reset); const processedLast = Validation.useProcessedLastRef(); + const pagesRef = useRef(); + if (!pagesRef.current) { + pagesRef.current = new LayoutPages(); + } useEffect(() => { if (layouts !== latestLayouts) { markNotReady('new layouts'); + pagesRef.current = new LayoutPages(); reset(latestLayouts, processedLast.current); } }, [latestLayouts, layouts, markNotReady, reset, processedLast]); @@ -616,13 +619,15 @@ function ProvideGlobalContext({ children, registry }: PropsWithChildren<{ regist } return ( - - {children} - + + + {children} + + ); } @@ -698,7 +703,6 @@ function InnerMarkAsReady() { const markReady = Store.useSelector((s) => s.markReady); const readiness = Store.useSelector((s) => s.readiness); const hiddenViaRulesRan = Store.useSelector((s) => s.hiddenViaRulesRan); - const hasNodes = Store.useSelector((state) => !!state.nodes); const stagesFinished = GeneratorStages.useIsFinished(); const hasUnsavedChanges = FD.useHasUnsavedChanges(); const registry = GeneratorInternal.useRegistry(); @@ -710,8 +714,7 @@ function InnerMarkAsReady() { const getAwaitingCommits = useGetAwaitingCommits(); const savingOk = readiness === NodesReadiness.WaitingUntilLastSaveHasProcessed ? !hasUnsavedChanges : true; - const checkNodeStates = - hasNodes && stagesFinished && savingOk && hiddenViaRulesRan && readiness !== NodesReadiness.Ready; + const checkNodeStates = stagesFinished && savingOk && hiddenViaRulesRan && readiness !== NodesReadiness.Ready; const nodeStateReady = Store.useSelector((state) => { if (!checkNodeStates) { @@ -819,19 +822,10 @@ function RegisterOnSaveFinished() { return null; } -function BlockUntilAlmostReady({ children }: PropsWithChildren) { - const ready = Store.useSelector((state) => state.nodes !== undefined); - if (!ready) { - return null; - } - - return <>{children}; -} - function BlockUntilLoaded({ children }: PropsWithChildren) { const hasBeenReady = useRef(false); const ready = Store.useSelector((state) => { - if (state.nodes && state.readiness === NodesReadiness.Ready) { + if (state.readiness === NodesReadiness.Ready) { hasBeenReady.current = true; return true; } @@ -869,8 +863,9 @@ type RetValFromNode = T extends LayoutNode */ export function useNode(id: T): RetValFromNode { const lastValue = useRef(NeverInitialized); + const nodes = useNodes(); const node = Store.useSelector((state) => { - if (!id || !state?.nodes) { + if (!id) { return undefined; } @@ -878,30 +873,32 @@ export function useNode(id: T): RetVa return lastValue.current; } - const node = id instanceof BaseLayoutNode ? id : state.nodes.findById(id); + const node = id instanceof BaseLayoutNode ? id : nodes.findById(id); lastValue.current = node; return node; }); return node as RetValFromNode; } -export const useGetPage = (pageId: string | undefined) => - Store.useSelector((state) => { +export const useGetPage = (pageId: string | undefined) => { + const nodes = useNodes(); + return Store.useSelector((state) => { if (!pageId) { return undefined; } - if (!state?.nodes) { + if (!nodes) { return undefined; } - return state.nodes.findLayout(new TraversalTask(state, state.nodes, undefined, undefined), pageId); + return nodes.findLayout(new TraversalTask(state, nodes, undefined, undefined), pageId); }); +}; -export const useNodes = () => WhenReady.useSelector((s) => s.nodes); -// This will rerender until it is no longer undefined, at that point it only rerenders when ready -export const useNodesWhenReadyOrInit = () => WhenReady.useSelector((s) => s.nodes, [undefined]); -export const useNodesWhenNotReady = () => Store.useSelector((s) => s.nodes); -export const useNodesLax = () => WhenReady.useLaxSelector((s) => s.nodes); +export const useNodes = () => useLayoutPages(); +export const useNodesLax = () => { + const out = useLaxLayoutPages(); + return out === ContextNotProvided ? undefined : out; +}; export interface IsHiddenOptions { /** @@ -1357,7 +1354,6 @@ export const NodesInternal = { useStore: () => Store.useStore(), useSetNodeProps: () => Store.useStaticSelector((s) => s.setNodeProps), - useSetNodes: () => Store.useStaticSelector((s) => s.setNodes), useAddPage: () => Store.useStaticSelector((s) => s.addPage), useSetPageProps: () => Store.useStaticSelector((s) => s.setPageProps), useAddNodes: () => Store.useStaticSelector((s) => s.addNodes), diff --git a/src/utils/layout/all.test.tsx b/src/utils/layout/all.test.tsx index 0a3857185a..f0e4de3a82 100644 --- a/src/utils/layout/all.test.tsx +++ b/src/utils/layout/all.test.tsx @@ -12,7 +12,7 @@ import { GenericComponent } from 'src/layout/GenericComponent'; import { fetchApplicationMetadata } from 'src/queries/queries'; import { ensureAppsDirIsSet, getAllApps } from 'src/test/allApps'; import { renderWithInstanceAndLayout } from 'src/test/renderWithProviders'; -import { NodesInternal } from 'src/utils/layout/NodesContext'; +import { NodesInternal, useNodes } from 'src/utils/layout/NodesContext'; import { TraversalTask } from 'src/utils/layout/useNodeTraversal'; import type { ExternalAppLayoutSet } from 'src/test/allApps'; @@ -57,7 +57,7 @@ function TestApp() { function RenderAllComponents() { const state = NodesInternal.useStore().getState(); - const nodes = state.nodes; + const nodes = useNodes(); if (!nodes) { throw new Error('No nodes found'); } diff --git a/src/utils/layout/generator/GeneratorDataSources.tsx b/src/utils/layout/generator/GeneratorDataSources.tsx index d1f9c28be0..bcec50a595 100644 --- a/src/utils/layout/generator/GeneratorDataSources.tsx +++ b/src/utils/layout/generator/GeneratorDataSources.tsx @@ -15,7 +15,7 @@ import { Validation } from 'src/features/validation/validationContext'; import { useMultipleDelayedSelectors } from 'src/hooks/delayedSelectors'; import { useShallowObjectMemo } from 'src/hooks/useShallowObjectMemo'; import { useCommitWhenFinished } from 'src/utils/layout/generator/CommitQueue'; -import { Hidden, NodesInternal, useNodesWhenReadyOrInit } from 'src/utils/layout/NodesContext'; +import { Hidden, NodesInternal, useNodes } from 'src/utils/layout/NodesContext'; import { useInnerDataModelBindingTranspose } from 'src/utils/layout/useDataModelBindingTranspose'; import { useInnerNodeFormDataSelector } from 'src/utils/layout/useNodeItem'; import { useInnerNodeTraversalSelector } from 'src/utils/layout/useNodeTraversal'; @@ -28,10 +28,7 @@ const { Provider, hooks } = createHookContext({ useDefaultDataType: () => DataModels.useDefaultDataType(), useReadableDataTypes: () => DataModels.useReadableDataTypes(), useExternalApis: () => useExternalApis(useApplicationMetadata().externalApiIds ?? []), - useNodes: () => useNodesWhenReadyOrInit(), - useIsForcedVisibleByDevTools: () => { - return useDevToolsStore((state) => state.isOpen && state.hiddenComponents !== 'hide'); - }, + useIsForcedVisibleByDevTools: () => useDevToolsStore((state) => state.isOpen && state.hiddenComponents !== 'hide'), useGetDataElementIdForDataType: () => DataModels.useGetDataElementIdForDataType(), useValidationsProcessedLast: () => Validation.useProcessedLast(), @@ -79,7 +76,7 @@ function useExpressionDataSources(): ExpressionDataSources { const currentLayoutSet = hooks.useCurrentLayoutSet() ?? null; const dataModelNames = hooks.useReadableDataTypes(); const externalApis = hooks.useExternalApis(); - const nodeTraversal = useInnerNodeTraversalSelector(hooks.useNodes(), dataSelectorForTraversal); + const nodeTraversal = useInnerNodeTraversalSelector(useNodes(), dataSelectorForTraversal); const transposeSelector = useInnerDataModelBindingTranspose(nodeDataSelector); const nodeFormDataSelector = useInnerNodeFormDataSelector(nodeDataSelector, formDataSelector); const langToolsSelector = useInnerLanguageWithForcedNodeSelector( diff --git a/src/utils/layout/generator/LayoutSetGenerator.tsx b/src/utils/layout/generator/LayoutSetGenerator.tsx index d0a4c1d78c..404154f8fc 100644 --- a/src/utils/layout/generator/LayoutSetGenerator.tsx +++ b/src/utils/layout/generator/LayoutSetGenerator.tsx @@ -11,16 +11,10 @@ import { GeneratorErrorBoundary, useGeneratorErrorBoundaryNodeRef, } from 'src/utils/layout/generator/GeneratorErrorBoundary'; -import { - GeneratorCondition, - GeneratorStages, - StageAddNodes, - StageMarkHidden, -} from 'src/utils/layout/generator/GeneratorStages'; +import { GeneratorCondition, StageAddNodes, StageMarkHidden } from 'src/utils/layout/generator/GeneratorStages'; import { useEvalExpressionInGenerator } from 'src/utils/layout/generator/useEvalExpression'; import { LayoutPage } from 'src/utils/layout/LayoutPage'; -import { LayoutPages } from 'src/utils/layout/LayoutPages'; -import { Hidden, NodesInternal, useNodesWhenNotReady } from 'src/utils/layout/NodesContext'; +import { Hidden, NodesInternal, useNodes } from 'src/utils/layout/NodesContext'; import type { CompExternal, CompExternalExact, CompTypes, ILayout } from 'src/layout/layout'; import type { BasicNodeGeneratorProps, @@ -29,6 +23,7 @@ import type { ContainerGeneratorProps, } from 'src/layout/LayoutComponent'; import type { ChildClaim, ChildClaims, ChildClaimsMap } from 'src/utils/layout/generator/GeneratorContext'; +import type { LayoutPages } from 'src/utils/layout/LayoutPages'; const style: React.CSSProperties = GeneratorDebug.displayState ? { @@ -53,11 +48,10 @@ interface ChildrenState { export function LayoutSetGenerator() { const layouts = GeneratorInternal.useLayouts(); - const pages = useMemo(() => new LayoutPages(), []); + const pages = useNodes(); const children = ( <> - {GeneratorDebug.displayState &&

Node generator

} {layouts && @@ -84,22 +78,6 @@ export function LayoutSetGenerator() { return
{children}
; } -function SaveFinishedNodesToStore({ pages }: { pages: LayoutPages }) { - const existingNodes = useNodesWhenNotReady(); - const setNodes = NodesInternal.useSetNodes(); - const isFinishedAddingNodes = GeneratorStages.useIsDoneAddingNodes(); - const numPages = Object.keys(GeneratorInternal.useLayouts()).length; - const shouldSet = existingNodes !== pages && pages && (isFinishedAddingNodes || numPages === 0); - - useEffect(() => { - if (shouldSet) { - setNodes(pages); - } - }, [pages, setNodes, shouldSet]); - - return null; -} - function ExportStores() { const nodesStore = NodesInternal.useStore(); diff --git a/src/utils/layout/useNodeTraversal.ts b/src/utils/layout/useNodeTraversal.ts index 7745918df9..2c9ff437b0 100644 --- a/src/utils/layout/useNodeTraversal.ts +++ b/src/utils/layout/useNodeTraversal.ts @@ -248,7 +248,7 @@ function useNodeTraversalProto(selector: (traverser: never) => Out, node?: const out = dataSelector( (state) => { - if (!nodes || nodes === ContextNotProvided) { + if (!nodes) { return ContextNotProvided; } @@ -332,7 +332,7 @@ function useInnerNodeTraversalSelectorProto( innerSelector: (traverser: NodeTraversalFromRoot) => InnerSelectorReturns, deps: unknown[], ): InnerSelectorReturns => { - if (!nodes || nodes === ContextNotProvided) { + if (!nodes) { return throwOrReturn(ContextNotProvided, strictness) as InnerSelectorReturns; } From 3d53163c1441f5e266a4886c8a1dd198d72998cf Mon Sep 17 00:00:00 2001 From: Ole Martin Handeland Date: Mon, 18 Nov 2024 10:43:07 +0100 Subject: [PATCH 20/25] Fixes after merge from main --- .../validation/ValidationStorePlugin.tsx | 3 ++- src/utils/layout/NodesContext.tsx | 20 +++++++++++-------- src/utils/layout/useNodeItem.ts | 10 +++------- src/utils/layout/useNodeTraversal.ts | 2 +- 4 files changed, 18 insertions(+), 17 deletions(-) diff --git a/src/features/validation/ValidationStorePlugin.tsx b/src/features/validation/ValidationStorePlugin.tsx index a8f087ea94..c78831b9ba 100644 --- a/src/features/validation/ValidationStorePlugin.tsx +++ b/src/features/validation/ValidationStorePlugin.tsx @@ -234,7 +234,8 @@ function getValidations({ state, node, mask, severity, includeHidden = false }: return emptyArray; } - if (!includeHidden && (!node || isHidden(state, node, hiddenOptions))) { + const nodes = node.page.layoutSet; + if (!includeHidden && (!node || isHidden(state, node, nodes, hiddenOptions))) { return emptyArray; } diff --git a/src/utils/layout/NodesContext.tsx b/src/utils/layout/NodesContext.tsx index 74fc1231d0..d844ceef6f 100644 --- a/src/utils/layout/NodesContext.tsx +++ b/src/utils/layout/NodesContext.tsx @@ -902,6 +902,7 @@ function isHiddenPage(state: NodesContext, page: LayoutPage | string | undefined export function isHidden( state: NodesContext, nodeOrId: LayoutNode | LayoutPage | undefined | string, + nodes: LayoutPages, _options?: IsHiddenOptions, ): boolean | undefined { if (!nodeOrId) { @@ -918,7 +919,7 @@ export function isHidden( } const id = typeof nodeOrId === 'string' ? nodeOrId : nodeOrId.id; - const node = state.nodes?.findById(id); + const node = nodes.findById(id); const hidden = state.nodeData[id]?.hidden; if (hidden === undefined) { return undefined; @@ -941,7 +942,7 @@ export function isHidden( } } - return isHidden(state, parent, options); + return isHidden(state, parent, nodes, options); } function makeOptions(forcedVisibleByDevTools: boolean, options?: AccessibleIsHiddenOptions): IsHiddenOptions { @@ -955,11 +956,12 @@ export type IsHiddenSelector = ReturnType; export const Hidden = { useIsHidden(node: LayoutNode | LayoutPage | undefined, options?: AccessibleIsHiddenOptions) { const forcedVisibleByDevTools = GeneratorData.useIsForcedVisibleByDevTools(); - return WhenReady.useMemoSelector((s) => isHidden(s, node, makeOptions(forcedVisibleByDevTools, options))); + const nodes = useNodes(); + return WhenReady.useSelector((s) => isHidden(s, node, nodes, makeOptions(forcedVisibleByDevTools, options))); }, useIsHiddenPage(page: LayoutPage | string | undefined, options?: AccessibleIsHiddenOptions) { const forcedVisibleByDevTools = GeneratorData.useIsForcedVisibleByDevTools(); - return WhenReady.useMemoSelector((s) => isHiddenPage(s, page, makeOptions(forcedVisibleByDevTools, options))); + return WhenReady.useSelector((s) => isHiddenPage(s, page, makeOptions(forcedVisibleByDevTools, options))); }, useIsHiddenPageSelector() { const forcedVisibleByDevTools = GeneratorData.useIsForcedVisibleByDevTools(); @@ -981,24 +983,26 @@ export const Hidden = { }, useIsHiddenSelector() { const forcedVisibleByDevTools = GeneratorData.useIsForcedVisibleByDevTools(); + const nodes = useNodes(); return Store.useDelayedSelector( { mode: 'simple', selector: (node: LayoutNode | LayoutPage | string, options?: IsHiddenOptions) => (state) => - isHidden(state, node, makeOptions(forcedVisibleByDevTools, options)), + isHidden(state, node, nodes, makeOptions(forcedVisibleByDevTools, options)), }, - [forcedVisibleByDevTools], + [forcedVisibleByDevTools, nodes], ); }, useIsHiddenSelectorProps() { const forcedVisibleByDevTools = GeneratorData.useIsForcedVisibleByDevTools(); + const nodes = useNodes(); return Store.useDelayedSelectorProps( { mode: 'simple', selector: (node: LayoutNode | LayoutPage, options?: IsHiddenOptions) => (state) => - isHidden(state, node, makeOptions(forcedVisibleByDevTools, options)), + isHidden(state, node, nodes, makeOptions(forcedVisibleByDevTools, options)), }, - [forcedVisibleByDevTools], + [forcedVisibleByDevTools, nodes], ); }, diff --git a/src/utils/layout/useNodeItem.ts b/src/utils/layout/useNodeItem.ts index b7ae4d29a0..fcd1287400 100644 --- a/src/utils/layout/useNodeItem.ts +++ b/src/utils/layout/useNodeItem.ts @@ -68,19 +68,15 @@ export function useNodeDirectChildren( restriction?: TraversalRestriction, ): LayoutNode[] { return ( - NodesInternal.useNodeData(parent, (nodeData, _, fullState) => { + NodesInternal.useNodeData(parent, (nodeData) => { if (!parent) { return emptyArray; } // eslint-disable-next-line @typescript-eslint/no-explicit-any const out = parent.def.pickDirectChildren(nodeData as any, restriction); - const rootNode = fullState.nodes; - if (!rootNode) { - return emptyArray; - } - - return out?.map((id) => rootNode.findById(id)).filter(typedBoolean); + const nodes = parent.page.layoutSet; + return out?.map((id) => nodes.findById(id)).filter(typedBoolean); }) ?? emptyArray ); } diff --git a/src/utils/layout/useNodeTraversal.ts b/src/utils/layout/useNodeTraversal.ts index 18e27a309d..a1ea1dd88f 100644 --- a/src/utils/layout/useNodeTraversal.ts +++ b/src/utils/layout/useNodeTraversal.ts @@ -52,7 +52,7 @@ export class TraversalTask { * Get the node object for a given ID */ public getNode(id: string): LayoutNode | undefined { - return this.state.nodes?.findById(id); + return this.rootNode.findById(id); } /** From a5e78c03cf9d3209d27c08595c29ab2547a6782a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rnar=20=C3=98sttveit?= Date: Mon, 18 Nov 2024 10:50:04 +0100 Subject: [PATCH 21/25] apply optimizations for regular useExpressionDataSources and add better typing for simple delayed selector props --- .../attachments/AttachmentsStorePlugin.tsx | 4 +- src/features/options/OptionsStorePlugin.tsx | 4 +- src/hooks/delayedSelectors.ts | 7 + .../layout/generator/GeneratorDataSources.tsx | 4 +- src/utils/layout/useExpressionDataSources.ts | 121 +++++++++--------- 5 files changed, 70 insertions(+), 70 deletions(-) diff --git a/src/features/attachments/AttachmentsStorePlugin.tsx b/src/features/attachments/AttachmentsStorePlugin.tsx index 2e81822aab..8b0b8033c2 100644 --- a/src/features/attachments/AttachmentsStorePlugin.tsx +++ b/src/features/attachments/AttachmentsStorePlugin.tsx @@ -33,7 +33,7 @@ import type { UploadedAttachment, } from 'src/features/attachments/index'; import type { BackendValidationIssue } from 'src/features/validation'; -import type { DSConfig, DSProps } from 'src/hooks/delayedSelectors'; +import type { DSPropsForSimpleSelector } from 'src/hooks/delayedSelectors'; import type { IDataModelBindingsList, IDataModelBindingsSimple } from 'src/layout/common.generated'; import type { CompWithBehavior } from 'src/layout/layout'; import type { IData } from 'src/types/shared'; @@ -84,7 +84,7 @@ export interface AttachmentsStorePluginConfig { useAttachments: (node: FileUploaderNode) => IAttachment[]; useAttachmentsSelector: () => AttachmentsSelector; - useAttachmentsSelectorProps: () => DSProps; + useAttachmentsSelectorProps: () => DSPropsForSimpleSelector; useWaitUntilUploaded: () => (node: FileUploaderNode, attachment: TemporaryAttachment) => Promise; useHasPendingAttachments: () => boolean; diff --git a/src/features/options/OptionsStorePlugin.tsx b/src/features/options/OptionsStorePlugin.tsx index 167ec66c38..e452476110 100644 --- a/src/features/options/OptionsStorePlugin.tsx +++ b/src/features/options/OptionsStorePlugin.tsx @@ -1,6 +1,6 @@ import { NodeDataPlugin } from 'src/utils/layout/plugins/NodeDataPlugin'; import type { IOptionInternal } from 'src/features/options/castOptionsToStrings'; -import type { DSConfig, DSProps } from 'src/hooks/delayedSelectors'; +import type { DSPropsForSimpleSelector } from 'src/hooks/delayedSelectors'; import type { CompWithBehavior } from 'src/layout/layout'; import type { LayoutNode } from 'src/utils/layout/LayoutNode'; import type { NodesContext, NodesStoreFull } from 'src/utils/layout/NodesContext'; @@ -17,7 +17,7 @@ export interface OptionsStorePluginConfig { extraHooks: { useNodeOptions: NodeOptionsSelector; useNodeOptionsSelector: () => NodeOptionsSelector; - useNodeOptionsSelectorProps: () => DSProps; + useNodeOptionsSelectorProps: () => DSPropsForSimpleSelector; }; } diff --git a/src/hooks/delayedSelectors.ts b/src/hooks/delayedSelectors.ts index 2e6a11c0aa..8d8250f0e2 100644 --- a/src/hooks/delayedSelectors.ts +++ b/src/hooks/delayedSelectors.ts @@ -389,6 +389,13 @@ export interface DSProps { type MultiDSProps = DSProps[]; +export type DSPropsForSimpleSelector< + Type, + SimpleSelector extends (...args: unknown[]) => unknown, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Strictness extends SelectorStrictness = any, +> = DSProps, ReturnType>, Strictness>>; + export type DSReturn = ModeFromConf extends SimpleArgMode ? (...args: Parameters) => ReturnType> diff --git a/src/utils/layout/generator/GeneratorDataSources.tsx b/src/utils/layout/generator/GeneratorDataSources.tsx index bcec50a595..aaae8f43d1 100644 --- a/src/utils/layout/generator/GeneratorDataSources.tsx +++ b/src/utils/layout/generator/GeneratorDataSources.tsx @@ -104,7 +104,7 @@ function useExpressionDataSources(): ExpressionDataSources { currentLayoutSet, externalApis, dataModelNames, - }) as ExpressionDataSources; + }); } function useValidationDataSources(): ValidationDataSources { @@ -138,5 +138,5 @@ function useValidationDataSources(): ValidationDataSources { currentLanguage, applicationMetadata, layoutSets, - }) as ValidationDataSources; + }); } diff --git a/src/utils/layout/useExpressionDataSources.ts b/src/utils/layout/useExpressionDataSources.ts index e8da8fce91..f70f8a6174 100644 --- a/src/utils/layout/useExpressionDataSources.ts +++ b/src/utils/layout/useExpressionDataSources.ts @@ -1,8 +1,5 @@ -import { useMemo } from 'react'; - import { useApplicationMetadata } from 'src/features/applicationMetadata/ApplicationMetadataProvider'; import { useApplicationSettings } from 'src/features/applicationSettings/ApplicationSettingsProvider'; -import { useAttachmentsSelector } from 'src/features/attachments/hooks'; import { DataModels } from 'src/features/datamodel/DataModelsProvider'; import { useExternalApis } from 'src/features/externalApi/useExternalApi'; import { useCurrentLayoutSet } from 'src/features/form/layoutSets/useCurrentLayoutSet'; @@ -10,12 +7,13 @@ import { FD } from 'src/features/formData/FormDataWrite'; import { useLaxInstanceDataSources } from 'src/features/instance/InstanceContext'; import { useLaxProcessData } from 'src/features/instance/ProcessContext'; import { useCurrentLanguage } from 'src/features/language/LanguageProvider'; -import { useLanguageWithForcedNodeSelector } from 'src/features/language/useLanguage'; -import { useNodeOptionsSelector } from 'src/features/options/useNodeOptions'; -import { Hidden, NodesInternal } from 'src/utils/layout/NodesContext'; -import { useDataModelBindingTranspose } from 'src/utils/layout/useDataModelBindingTranspose'; -import { useNodeFormDataSelector } from 'src/utils/layout/useNodeItem'; -import { useNodeTraversalSelector } from 'src/utils/layout/useNodeTraversal'; +import { useInnerLanguageWithForcedNodeSelector } from 'src/features/language/useLanguage'; +import { useMultipleDelayedSelectors } from 'src/hooks/delayedSelectors'; +import { useShallowObjectMemo } from 'src/hooks/useShallowObjectMemo'; +import { Hidden, NodesInternal, useNodes } from 'src/utils/layout/NodesContext'; +import { useInnerDataModelBindingTranspose } from 'src/utils/layout/useDataModelBindingTranspose'; +import { useInnerNodeFormDataSelector } from 'src/utils/layout/useNodeItem'; +import { useInnerNodeTraversalSelector } from 'src/utils/layout/useNodeTraversal'; import type { AttachmentsSelector } from 'src/features/attachments/AttachmentsStorePlugin'; import type { ExternalApisResult } from 'src/features/externalApi/useExternalApi'; import type { IUseLanguage } from 'src/features/language/useLanguage'; @@ -50,64 +48,59 @@ export interface ExpressionDataSources { } export function useExpressionDataSources(): ExpressionDataSources { - const instanceDataSources = useLaxInstanceDataSources(); - const formDataSelector = FD.useDebouncedSelector(); - const formDataRowsSelector = FD.useDebouncedRowsSelector(); - const attachmentsSelector = useAttachmentsSelector(); - const optionsSelector = useNodeOptionsSelector(); + const [ + formDataSelector, + formDataRowsSelector, + attachmentsSelector, + optionsSelector, + nodeDataSelector, + dataSelectorForTraversal, + isHiddenSelector, + ] = useMultipleDelayedSelectors( + FD.useDebouncedSelectorProps(), + FD.useDebouncedRowsSelectorProps(), + NodesInternal.useAttachmentsSelectorProps(), + NodesInternal.useNodeOptionsSelectorProps(), + NodesInternal.useNodeDataSelectorProps(), + NodesInternal.useDataSelectorForTraversalProps(), + Hidden.useIsHiddenSelectorProps(), + ); + const process = useLaxProcessData(); const applicationSettings = useApplicationSettings(); - const langToolsSelector = useLanguageWithForcedNodeSelector(); const currentLanguage = useCurrentLanguage(); - const isHiddenSelector = Hidden.useIsHiddenSelector(); - const nodeFormDataSelector = useNodeFormDataSelector(); - const nodeDataSelector = NodesInternal.useNodeDataSelector(); - const nodeTraversal = useNodeTraversalSelector(); - const transposeSelector = useDataModelBindingTranspose(); - const currentLayoutSet = useCurrentLayoutSet() ?? null; - const readableDataModels = DataModels.useReadableDataTypes(); - const externalApiIds = useApplicationMetadata().externalApiIds ?? []; - const externalApis = useExternalApis(externalApiIds); - - return useMemo( - () => ({ - formDataSelector, - formDataRowsSelector, - attachmentsSelector, - process, - optionsSelector, - applicationSettings, - instanceDataSources, - langToolsSelector, - currentLanguage, - isHiddenSelector, - nodeFormDataSelector, - nodeDataSelector, - nodeTraversal, - transposeSelector, - currentLayoutSet, - externalApis, - dataModelNames: readableDataModels, - }), - [ - formDataSelector, - formDataRowsSelector, - attachmentsSelector, - process, - optionsSelector, - applicationSettings, - instanceDataSources, - langToolsSelector, - currentLanguage, - isHiddenSelector, - nodeFormDataSelector, - nodeDataSelector, - nodeTraversal, - transposeSelector, - currentLayoutSet, - externalApis, - readableDataModels, - ], + const instanceDataSources = useLaxInstanceDataSources(); + const currentLayoutSet = useCurrentLayoutSet() ?? null; + const dataModelNames = DataModels.useReadableDataTypes(); + const externalApis = useExternalApis(useApplicationMetadata().externalApiIds ?? []); + const nodeTraversal = useInnerNodeTraversalSelector(useNodes(), dataSelectorForTraversal); + const transposeSelector = useInnerDataModelBindingTranspose(nodeDataSelector); + const nodeFormDataSelector = useInnerNodeFormDataSelector(nodeDataSelector, formDataSelector); + const langToolsSelector = useInnerLanguageWithForcedNodeSelector( + DataModels.useDefaultDataType(), + dataModelNames, + formDataSelector, + nodeDataSelector, ); + + return useShallowObjectMemo({ + formDataSelector, + formDataRowsSelector, + attachmentsSelector, + optionsSelector, + nodeDataSelector, + process, + applicationSettings, + instanceDataSources, + langToolsSelector, + currentLanguage, + isHiddenSelector, + nodeFormDataSelector, + nodeTraversal, + transposeSelector, + currentLayoutSet, + externalApis, + dataModelNames, + }); } From a9f56a79ade3efd22171fc89e4219787f97a9fa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rnar=20=C3=98sttveit?= Date: Mon, 18 Nov 2024 11:52:18 +0100 Subject: [PATCH 22/25] handle case where store is subscribed to and updates before uSES has subscribed --- src/hooks/delayedSelectors.ts | 33 +++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/src/hooks/delayedSelectors.ts b/src/hooks/delayedSelectors.ts index 8d8250f0e2..329ca36f13 100644 --- a/src/hooks/delayedSelectors.ts +++ b/src/hooks/delayedSelectors.ts @@ -217,16 +217,28 @@ abstract class BaseDelayedSelector { } class SingleDelayedSelectorController extends BaseDelayedSelector { - private triggerRender: () => void; + // Subscription does not happen synchronously, but as an effect, meaning that there is a window of time + // where selectors could be used (and updated) before we have the ability to trigger a re-render. + // See: https://github.com/facebook/react/blob/92c0f5f85fed42024b17bf6595291f9f5d6e8734/packages/react-reconciler/src/ReactFiberHooks.js#L1715-L1716 + private triggerRender: (() => void) | null = null; + private shouldTriggerOnSubscribe = false; public getSnapshot = () => this.selectorFunc; public subscribe = (callback: () => void) => { this.triggerRender = callback; + if (this.shouldTriggerOnSubscribe) { + this.triggerRender(); + this.shouldTriggerOnSubscribe = false; + } return () => this.unsubscribeFromStore(); }; protected onUpdateSelector(): void { - this.triggerRender(); + if (this.triggerRender) { + this.triggerRender(); + } else { + this.shouldTriggerOnSubscribe = true; + } } } @@ -258,10 +270,15 @@ class MultiDelayedSelector extends BaseDelayedSelector { } class MultiDelayedSelectorController

{ + // Subscription does not happen synchronously, but as an effect, meaning that there is a window of time + // where selectors could be used (and updated) before we have the ability to trigger a re-render. + // See: https://github.com/facebook/react/blob/92c0f5f85fed42024b17bf6595291f9f5d6e8734/packages/react-reconciler/src/ReactFiberHooks.js#L1715-L1716 + private triggerRender: (() => void) | null = null; + private shouldTriggerOnSubscribe = false; + private changeCount = 0; private controllers: MultiDelayedSelector[] = []; private selectorFuncs: DSReturn[] = []; - private triggerRender: () => void; constructor(props: P) { for (let i = 0; i < props.length; i++) { @@ -277,6 +294,10 @@ class MultiDelayedSelectorController

{ public subscribe = (callback: () => void) => { this.triggerRender = callback; + if (this.shouldTriggerOnSubscribe) { + this.triggerRender(); + this.shouldTriggerOnSubscribe = false; + } return () => this.controllers.forEach((c) => c.unsubscribeFromStore()); }; @@ -291,7 +312,11 @@ class MultiDelayedSelectorController

{ this.selectorFuncs[index] = this.controllers[index].getSelectorFunc(); if (lastSelectChangeCount === this.changeCount) { this.selectorFuncs = [...this.selectorFuncs]; - this.triggerRender(); + if (this.triggerRender) { + this.triggerRender(); + } else { + this.shouldTriggerOnSubscribe = true; + } } this.changeCount += 1; this.controllers.forEach((ds) => ds.setChangeCount(this.changeCount)); From 45c82bfb7fa6f9d7995426a8d42ccc9ac2ec6be9 Mon Sep 17 00:00:00 2001 From: Ole Martin Handeland Date: Mon, 18 Nov 2024 14:08:37 +0100 Subject: [PATCH 23/25] Rewriting SubformWrapper to ensure FormProvider is not fully re-rendered when the route changes. This caused a node item to remain 'undefined', blocking the state from becoming ready. Also adding a tripwire for this. --- src/features/form/FormContext.tsx | 11 +++++++++- src/features/routing/AppRoutingContext.tsx | 2 +- src/layout/Subform/SubformWrapper.tsx | 24 +++++++++++++++++----- src/layout/Subform/index.tsx | 17 ++------------- 4 files changed, 32 insertions(+), 22 deletions(-) diff --git a/src/features/form/FormContext.tsx b/src/features/form/FormContext.tsx index 9ec0c2ce86..40ef87720e 100644 --- a/src/features/form/FormContext.tsx +++ b/src/features/form/FormContext.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useRef } from 'react'; import { ContextNotProvided, createContext } from 'src/core/contexts/context'; import { DataModelsProvider } from 'src/features/datamodel/DataModelsProvider'; @@ -32,6 +32,15 @@ export function useIsInFormContext() { */ export function FormProvider({ children }: React.PropsWithChildren) { const hasProcess = useHasProcessProvider(); + const renderCount = useRef(0); + renderCount.current += 1; + + if (renderCount.current > 1) { + console.error( + `FormProvider re-rendered. This may cause all nodes to be re-created and may trash ` + + `performance. Consider optimizing routes and components to avoid this.`, + ); + } return ( <> diff --git a/src/features/routing/AppRoutingContext.tsx b/src/features/routing/AppRoutingContext.tsx index a7bff35efe..8f54cd2524 100644 --- a/src/features/routing/AppRoutingContext.tsx +++ b/src/features/routing/AppRoutingContext.tsx @@ -121,7 +121,7 @@ export const useNavigationParam = (key: T) => return paramFrom(matches, key) as PathParams[T]; }); -export const useIsCurrentView = (pageKey: string) => +export const useIsCurrentView = (pageKey: string | undefined) => useSelector((s) => { const path = getPath(s.hash); const matches = matchers.map((matcher) => matchPath(matcher, path)); diff --git a/src/layout/Subform/SubformWrapper.tsx b/src/layout/Subform/SubformWrapper.tsx index d54939e7db..57086eb08b 100644 --- a/src/layout/Subform/SubformWrapper.tsx +++ b/src/layout/Subform/SubformWrapper.tsx @@ -1,24 +1,38 @@ import React, { useEffect } from 'react'; -import type { PropsWithChildren } from 'react'; +import { Form, FormFirstPage } from 'src/components/form/Form'; import { useTaskStore } from 'src/core/contexts/taskStoreContext'; import { Loader } from 'src/core/loading/Loader'; import { FormProvider } from 'src/features/form/FormContext'; import { useDataTypeFromLayoutSet } from 'src/features/form/layout/LayoutsContext'; -import { useNavigationParam } from 'src/features/routing/AppRoutingContext'; +import { useIsCurrentView, useNavigationParam } from 'src/features/routing/AppRoutingContext'; import { useNavigatePage } from 'src/hooks/useNavigatePage'; import { useNodeItem } from 'src/utils/layout/useNodeItem'; import type { LayoutNode } from 'src/utils/layout/LayoutNode'; -export const SubformWrapper = ({ node, children }: PropsWithChildren<{ node: LayoutNode<'Subform'> }>) => { +export function SubformWrapper({ node }: { node: LayoutNode<'Subform'> }) { const isDone = useDoOverride(node); if (!isDone) { return ; } - return {children}; -}; + return ( + + + + ); +} + +function SubformForm() { + const hasFormPage = !useIsCurrentView(undefined); + + if (hasFormPage) { + return

; + } + + return ; +} export const RedirectBackToMainForm = () => { const mainPageKey = useNavigationParam('mainPageKey'); diff --git a/src/layout/Subform/index.tsx b/src/layout/Subform/index.tsx index 2c814e843d..55eb53a7cc 100644 --- a/src/layout/Subform/index.tsx +++ b/src/layout/Subform/index.tsx @@ -2,7 +2,6 @@ import React, { forwardRef } from 'react'; import { Route, Routes } from 'react-router-dom'; import type { JSX, ReactNode } from 'react'; -import { Form, FormFirstPage } from 'src/components/form/Form'; import { TaskStoreProvider } from 'src/core/contexts/taskStoreContext'; import { type ComponentValidation, @@ -36,20 +35,8 @@ export class Subform extends SubformDef implements ValidateComponent<'Subform'>, - - - } - /> - - - - } + path=':dataElementId/:subformPage?' + element={} /> Date: Mon, 18 Nov 2024 14:38:57 +0100 Subject: [PATCH 24/25] separate setDataElementId from setInitialData and remove annoying workarounds --- src/features/datamodel/DataModelsProvider.tsx | 68 +++++++++---------- .../backendValidationQuery.ts | 1 - test/e2e/support/custom.ts | 1 - 3 files changed, 33 insertions(+), 37 deletions(-) diff --git a/src/features/datamodel/DataModelsProvider.tsx b/src/features/datamodel/DataModelsProvider.tsx index 2c74dee38c..0f3a6d8c61 100644 --- a/src/features/datamodel/DataModelsProvider.tsx +++ b/src/features/datamodel/DataModelsProvider.tsx @@ -53,7 +53,8 @@ interface DataModelsMethods { defaultDataType: string | undefined, layoutSetId: string | undefined, ) => void; - setInitialData: (dataType: string, initialData: object, dataElementId: string | null) => void; + setInitialData: (dataType: string, initialData: object) => void; + setDataElementId: (dataType: string, dataElementId: string | null) => void; setDataModelSchema: (dataType: string, schema: JSONSchema7, lookupTool: SchemaLookupTool) => void; setExpressionValidationConfig: (dataType: string, config: IExpressionValidations | null) => void; setError: (error: Error) => void; @@ -78,16 +79,18 @@ function initialCreateStore() { writableDataTypes, defaultDataType, layoutSetId, - initialData: {}, - dataElementIds: {}, })); }, - setInitialData: (dataType, initialData, dataElementId) => { + setInitialData: (dataType, initialData) => { set((state) => ({ initialData: { ...state.initialData, [dataType]: initialData, }, + })); + }, + setDataElementId: (dataType, dataElementId) => { + set((state) => ({ dataElementIds: { ...state.dataElementIds, [dataType]: dataElementId, @@ -193,39 +196,29 @@ function DataModelsLoader() { setDataTypes(allValidDataTypes, writableDataTypes, defaultDataType, layoutSetId); }, [applicationMetadata, defaultDataType, isStateless, layouts, setDataTypes, dataElements, layoutSetId]); - const initialDataDone = useSelector((state) => Object.keys(state.initialData)); - const schemaDone = useSelector((state) => Object.keys(state.schemas)); - const validationConfigDone = useSelector((state) => Object.keys(state.expressionValidationConfigs)); - // We should load form data and schema for all referenced data models, schema is used for dataModelBinding validation which we want to do even if it is readonly // We only need to load expression validation config for data types that are not readonly. Additionally, backend will error if we try to validate a model we are not supposed to return ( <> - {allDataTypes - ?.filter((dataType) => !initialDataDone.includes(dataType)) - .map((dataType) => ( - - ))} - {allDataTypes - ?.filter((dataType) => !schemaDone.includes(dataType)) - .map((dataType) => ( - - ))} - {writableDataTypes - ?.filter((dataType) => !validationConfigDone.includes(dataType)) - .map((dataType) => ( - - ))} + {allDataTypes?.map((dataType) => ( + + ))} + {allDataTypes?.map((dataType) => ( + + ))} + {writableDataTypes?.map((dataType) => ( + + ))} ); } @@ -283,6 +276,7 @@ interface LoaderProps { function LoadInitialData({ dataType, overrideDataElement }: LoaderProps & { overrideDataElement?: string }) { const setInitialData = useSelector((state) => state.setInitialData); + const setDataElementId = useSelector((state) => state.setDataElementId); const setError = useSelector((state) => state.setError); const dataElements = useLaxInstanceDataElements(dataType); const dataElementId = overrideDataElement ?? getFirstDataElementId(dataElements, dataType); @@ -290,9 +284,13 @@ function LoadInitialData({ dataType, overrideDataElement }: LoaderProps & { over const { data, error } = useFormDataQuery(url); useEffect(() => { if (data && url) { - setInitialData(dataType, data, dataElementId ?? null); + setInitialData(dataType, data); } - }, [data, dataElementId, dataType, setInitialData, url]); + }, [data, dataType, setInitialData, url]); + + useEffect(() => { + setDataElementId(dataType, dataElementId ?? null); + }, [dataElementId, dataType, setDataElementId]); useEffect(() => { error && setError(error); diff --git a/src/features/validation/backendValidation/backendValidationQuery.ts b/src/features/validation/backendValidation/backendValidationQuery.ts index 85f08b6c6a..97c297a62a 100644 --- a/src/features/validation/backendValidation/backendValidationQuery.ts +++ b/src/features/validation/backendValidation/backendValidationQuery.ts @@ -148,7 +148,6 @@ export function useBackendValidationQuery(enabled: boolean) { queryKey, queryFn, enabled, - staleTime: 0, gcTime: 0, }); diff --git a/test/e2e/support/custom.ts b/test/e2e/support/custom.ts index 22977f51b1..1e0e1682c1 100644 --- a/test/e2e/support/custom.ts +++ b/test/e2e/support/custom.ts @@ -478,7 +478,6 @@ Cypress.Commands.add('interceptLayout', (taskName, mutator, wholeLayoutMutator, Cypress.Commands.add('changeLayout', (mutator, wholeLayoutMutator) => { cy.log('Changing current layout'); - cy.waitUntilSaved(); cy.window().then((win) => { const activeData = win.queryClient.getQueryCache().findAll({ type: 'active' }); for (const query of activeData) { From 2cc1219e8cd188331f5f47be64d9ecb6ac7baa4c Mon Sep 17 00:00:00 2001 From: Ole Martin Handeland Date: Mon, 18 Nov 2024 14:40:32 +0100 Subject: [PATCH 25/25] Making sure node.item is set again in case it disappears because of re-renders of FormProvider (which in turn causes all-node removal and re-adds). Requiring conditions on all set-hooks in the future. --- src/utils/layout/generator/CommitQueue.tsx | 7 ++++--- src/utils/layout/generator/NodeGenerator.tsx | 18 +++++++++++++++++- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/utils/layout/generator/CommitQueue.tsx b/src/utils/layout/generator/CommitQueue.tsx index 9ab295cde5..4a68c585b6 100644 --- a/src/utils/layout/generator/CommitQueue.tsx +++ b/src/utils/layout/generator/CommitQueue.tsx @@ -138,11 +138,12 @@ export const NodesStateQueue = { useAddNode: (req: AddNodeRequest, condition = true) => useAddToQueue('addNodes', false, req, condition), useRemoveNode: (req: Omit) => useRemoveNode(req), // eslint-disable-next-line @typescript-eslint/no-explicit-any - useSetNodeProp: (req: SetNodePropRequest, condition = true) => + useSetNodeProp: (req: SetNodePropRequest, condition: boolean) => useAddToQueue('setNodeProps', true, req, condition), - useSetRowExtras: (req: SetRowExtrasRequest, condition = true) => useAddToQueue('setRowExtras', true, req, condition), + useSetRowExtras: (req: SetRowExtrasRequest, condition: boolean) => + useAddToQueue('setRowExtras', true, req, condition), // eslint-disable-next-line @typescript-eslint/no-explicit-any - useSetPageProp: (req: SetPagePropRequest, condition = true) => + useSetPageProp: (req: SetPagePropRequest, condition: boolean) => useAddToQueue('setPageProps', true, req, condition), }; diff --git a/src/utils/layout/generator/NodeGenerator.tsx b/src/utils/layout/generator/NodeGenerator.tsx index 1bcd97759f..dbfc71f12b 100644 --- a/src/utils/layout/generator/NodeGenerator.tsx +++ b/src/utils/layout/generator/NodeGenerator.tsx @@ -1,6 +1,8 @@ import React, { useCallback, useMemo } from 'react'; import type { PropsWithChildren } from 'react'; +import deepEqual from 'fast-deep-equal'; + import { evalExpr } from 'src/features/expressions'; import { ExprVal } from 'src/features/expressions/types'; import { ExprValidation } from 'src/features/expressions/validation'; @@ -144,7 +146,21 @@ function ResolveExpressions({ node, intermediateItem }: Com [def, resolverProps], ); - NodesStateQueue.useSetNodeProp({ node, prop: 'item', value: resolved, partial: true }); + const isSet = NodesInternal.useNodeData(node, (data) => { + if (!data.item) { + return false; + } + + for (const key in resolved) { + if (!deepEqual(data.item[key], resolved[key])) { + return false; + } + } + + return true; + }); + + NodesStateQueue.useSetNodeProp({ node, prop: 'item', value: resolved, partial: true }, !isSet); return ( <>{GeneratorDebug.displayState &&
{JSON.stringify(resolved, null, 2)}
}