diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 3d04f0096..c6a97c2e6 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -46,6 +46,7 @@ module.exports = { files: ['**/*.{ts,tsx}'], parser: '@typescript-eslint/parser', parserOptions: { + ecmaVersion: 'latest', ecmaFeatures: { jsx: true }, tsconfigRootDir, project: ['*/tsconfig.json'], @@ -72,7 +73,10 @@ module.exports = { 'react-hooks/rules-of-hooks': 'error', 'react-hooks/exhaustive-deps': [ 'warn', - { additionalHooks: 'useAsyncData' }, + { + additionalHooks: + 'usePromise|useLazyPromise|usePromiseOnSituationChange', + }, ], '@typescript-eslint/no-unsafe-call': 'warn', diff --git a/site/source/engine.worker.ts b/site/source/engine.worker.ts new file mode 100644 index 000000000..04e1e0571 --- /dev/null +++ b/site/source/engine.worker.ts @@ -0,0 +1,203 @@ +import rawRules, { DottedName } from 'modele-social' +import Engine, { Rule } from 'publicodes' + +import type { ProviderProps } from '@/components/Provider' + +import i18n from './locales/i18n' +import ruleTranslations from './locales/rules-en.yaml' +import translateRules from './locales/translateRules' + +type Rules = Record + +const unitsTranslations = Object.entries( + i18n.getResourceBundle('fr', 'units') as Record +) +const engineOptions = { + getUnitKey(unit: string): string { + const key = unitsTranslations + .find(([, trans]) => trans === unit)?.[0] + .replace(/_plural$/, '') + + return key || unit + }, +} + +let warnCount = 0 +let timeout: NodeJS.Timeout | null = null +const logger = { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + warn: (message: string) => { + // console.warn(message) + + warnCount++ + timeout !== null && clearTimeout(timeout) + timeout = setTimeout(() => { + // eslint-disable-next-line no-console + console.warn('⚠️', warnCount, 'warnings in the engine') + warnCount = 0 + }, 1000) + }, + error: (message: string) => { + // eslint-disable-next-line no-console + console.error(message) + }, + log: (message: string) => { + // eslint-disable-next-line no-console + console.log(message) + }, +} + +export function engineFactory(rules: Rules, options = {}) { + return new Engine(rules, { ...engineOptions, ...options, logger }) +} + +// + +export type Actions = + | { + action: 'init' + params: [{ basename: ProviderProps['basename'] }] + result: void + } + | { + action: 'setSituation' + params: Parameters['setSituation']> + result: void + } + | { + action: 'evaluate' + params: Parameters['evaluate']> + result: ReturnType['evaluate']> + } + | { + action: 'getRule' + params: Parameters['getRule']> + result: ReturnType['getRule']> + } + | { + action: 'getParsedRules' + params: [] + result: ReturnType['getParsedRules']> + } + | { + action: 'shallowCopy' + params: [] + result: void + } + | { + action: 'deleteShallowCopy' + params: [{ engineId: number }] + result: void + } + +type GenericParams = { + /** + * The id of the engine to use, the default engine is 0 + */ + engineId?: number + + /** + * The id of the message, used to identify the response + */ + id: number +} + +export type Action = Extract< + Actions, + { action: T } +> + +let engines: (Engine | undefined)[] = [] + +let setDefaultEngineReady: (() => void) | null = null +const isDefaultEngineReady = new Promise( + (resolve) => (setDefaultEngineReady = resolve as () => void) +) + +onmessage = async (e) => { + console.log('[onmessage]', e.data) + + const { engineId = 0, id, action, params } = e.data as Actions & GenericParams + + try { + if (action === 'init') { + const [{ basename }] = params + try { + let rules = rawRules + if (basename === 'infrance') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + rules = translateRules('en', ruleTranslations, rules) + } + + const engineId = engines.length + engines.push(engineFactory(rules)) + console.log('[engine ready]', engines[engineId]) + postMessage({ engineId, id }) + setDefaultEngineReady?.() + } catch (e) { + console.error('[error]', e) + // postMessage('error') + } + + return + } + + await isDefaultEngineReady + + const engine = engines[engineId] + if (!engine) { + throw new Error('Engine does not exist') + } + + if (action === 'setSituation') { + // safeSetSituation( + // engine, + // ({ situation, }) => { + // console.error('setSituation', { situation, faultyDottedName }) + // }, + // ...params + // ) + engine.setSituation(...params) + + return postMessage({ engineId, id }) + } else if (action === 'evaluate') { + const result = engine.evaluate(...params) + console.log('[result]', result) + + return postMessage({ engineId, id, result }) + } else if (action === 'getRule') { + const result = engine.getRule(...params) + + return postMessage({ engineId, id, result }) + } else if (action === 'getParsedRules') { + const result = engine.getParsedRules() + + return postMessage({ engineId, id, result }) + } else if (action === 'shallowCopy') { + const result = engine.shallowCopy() + engines.push(result) + + return postMessage({ engineId: engines.length - 1, id }) + } else if (action === 'deleteShallowCopy') { + if (engineId === 0) { + throw new Error('Cannot delete the default engine') + } + delete engines[engineId] + + // false positive warning from eslint + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call + const lastIndex: number = engines.findLastIndex( + (el) => el instanceof Engine + ) + engines = lastIndex >= 0 ? engines.splice(0, lastIndex) : engines + + console.log('[engines]', engines) + + return postMessage({ engineId, id }) + } else { + console.log('[Message inconu]', e.data) + } + } catch (error) { + return postMessage({ engineId, id, error }) + } +} diff --git a/site/source/hooks/useAsyncData.ts b/site/source/hooks/useAsyncData.ts deleted file mode 100644 index d4f052cb7..000000000 --- a/site/source/hooks/useAsyncData.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { DependencyList, useEffect, useState } from 'react' - -/** - * Executes a function that returns a promise (when dependencies change) - * and returns the result of the promise when completed - */ -export const useAsyncData = ( - getAsyncData: () => Promise, - defaultValue: U | null = null, - deps: DependencyList = [] -): T | U | null => { - const [state, setState] = useState(defaultValue) - - useEffect(() => { - void (async () => { - setState(await getAsyncData()) - })() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, deps) - - return state -} diff --git a/site/source/hooks/usePromise.ts b/site/source/hooks/usePromise.ts new file mode 100644 index 000000000..06d25bc69 --- /dev/null +++ b/site/source/hooks/usePromise.ts @@ -0,0 +1,51 @@ +import { DependencyList, useCallback, useEffect, useState } from 'react' + +/** + * Execute an asynchronous function and return its result (Return default value if the promise is not finished). + * The function is executed each time the dependencies change. + */ +export const usePromise = ( + promise: () => Promise, + deps: DependencyList = [], + defaultValue?: Default +) => { + // eslint-disable-next-line react-hooks/exhaustive-deps + const [state, lazyPromise] = useLazyPromise(promise, deps, defaultValue) + + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => void lazyPromise(), deps) + + return state +} + +/** + * Execute an asynchronous function and return its result (Return default value if the promise is not finished). + * Use this hook if you want to fire the promise manually. + */ +export const useLazyPromise = < + T, + Params extends unknown[], + Default = undefined +>( + promise: (...params: Params) => Promise, + deps: DependencyList = [], + defaultValue?: Default +) => { + // console.log('===>', defaultValue) + const [state, setState] = useState(defaultValue as Default) + + const lazyPromise = useCallback( + async (...params: Params) => { + // console.log('====', defaultValue) + + const result = await promise(...params) + setState(result) + + return result as T + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + deps + ) + + return [state, lazyPromise] as const +} diff --git a/site/source/pages/assistants/recherche-code-ape/GuichetInfo.tsx b/site/source/pages/assistants/recherche-code-ape/GuichetInfo.tsx index 0c81fe82f..14e66b9b2 100644 --- a/site/source/pages/assistants/recherche-code-ape/GuichetInfo.tsx +++ b/site/source/pages/assistants/recherche-code-ape/GuichetInfo.tsx @@ -5,7 +5,7 @@ import { Strong } from '@/design-system/typography' import { H5 } from '@/design-system/typography/heading' import { Li, Ul } from '@/design-system/typography/list' import { Body } from '@/design-system/typography/paragraphs' -import { useAsyncData } from '@/hooks/useAsyncData' +import { usePromise } from '@/hooks/usePromise' import { capitalise0 } from '@/utils' const lazyApeToGuichet = () => import('@/public/data/ape-to-guichet.json') @@ -16,8 +16,8 @@ type Guichet = typeof import('@/public/data/guichet.json') export type GuichetEntry = Guichet[keyof Guichet] export function useGuichetInfo(codeApe?: string): GuichetEntry[] | null { - const guichet = useAsyncData(lazyGuichet)?.default - const apeToGuichet = useAsyncData(lazyApeToGuichet)?.default + const guichet = usePromise(lazyGuichet)?.default + const apeToGuichet = usePromise(lazyApeToGuichet)?.default return useMemo(() => { if (!codeApe || !guichet || !apeToGuichet || !(codeApe in apeToGuichet)) { diff --git a/site/source/pages/assistants/recherche-code-ape/SearchCodeAPE.tsx b/site/source/pages/assistants/recherche-code-ape/SearchCodeAPE.tsx index f9470ec6b..fc3e0c63f 100644 --- a/site/source/pages/assistants/recherche-code-ape/SearchCodeAPE.tsx +++ b/site/source/pages/assistants/recherche-code-ape/SearchCodeAPE.tsx @@ -11,7 +11,7 @@ import { VisibleRadio } from '@/design-system/field/Radio/Radio' import { RadioCardSkeleton } from '@/design-system/field/Radio/RadioCard' import { Spacing } from '@/design-system/layout' import { SmallBody } from '@/design-system/typography/paragraphs' -import { useAsyncData } from '@/hooks/useAsyncData' +import { usePromise } from '@/hooks/usePromise' import { Result } from './Result' @@ -102,12 +102,15 @@ export default function SearchCodeAPE({ [] ) - const lazyData = useAsyncData(() => import('@/public/data/ape-search.json')) + const lazyData = usePromise(() => import('@/public/data/ape-search.json')) const lastIdxs = useRef>({}) const prevValue = useRef(searchQuery) - const buildedResearch = useMemo(() => buildResearch(lazyData), [lazyData]) + const buildedResearch = useMemo( + () => lazyData && buildResearch(lazyData), + [lazyData] + ) useEffect(() => { if (!lazyData || !buildedResearch) { diff --git a/site/source/utils/index.ts b/site/source/utils/index.ts index b6f552df9..1f15606e2 100644 --- a/site/source/utils/index.ts +++ b/site/source/utils/index.ts @@ -283,8 +283,25 @@ export const generateUuid = () => { } /** - * Returns true if x is not null, useful for filtering out nulls from arrays + * Returns true if value is not null, useful for filtering out nulls from arrays * @example [1, null, 2].filter(isNotNull) // [1, 2] - * @param x + * @param value */ -export const isNotNull = (x: T | null): x is T => x !== null +export const isNotNull = (value: T | null): value is T => value !== null + +/** + * Returns true if value is not undefined, useful for filtering out undefined from arrays + * @example [1, undefined, 2].filter(isDefined) // [1, 2] + * @param value + */ +export const isDefined = (value: T | undefined): value is T => + value !== undefined + +/** + * Returns true if value is not null or undefined, useful for filtering out nulls and undefined from arrays + * @example [1, null, undefined, 2].filter(isNotNullOrUndefined) // [1, 2] + * @param value + */ +export const isNotNullOrUndefined = ( + value: T | null | undefined +): value is T => isNotNull(value) && isDefined(value) diff --git a/site/source/workerEngine.tsx b/site/source/workerEngine.tsx new file mode 100644 index 000000000..4a4a4f9b6 --- /dev/null +++ b/site/source/workerEngine.tsx @@ -0,0 +1,350 @@ +import { DottedName } from 'modele-social' +import React, { DependencyList, useContext, useEffect, useState } from 'react' + +import { ProviderProps } from './components/Provider' +import type { Action, Actions } from './engine.worker' +import EngineWorker from './engine.worker?worker' +import { usePromise } from './hooks/usePromise' + +if (!window.Worker) { + throw new Error('Worker is not supported in this browser') +} + +interface WorkerEngine { + worker: Worker + postMessage: >( + engineId: number, + action: T, + ...params: U['params'] + ) => Promise + isWorkerReady: Promise + promises: { + resolve: (value: unknown) => void + reject: (value: unknown) => void + }[] +} + +const initWorkerEngine = ( + basename: ProviderProps['basename'], + setSituationVersion: (updater: (situationVersion: number) => number) => void +) => { + const newWorker: Worker = new EngineWorker() + + const promises: WorkerEngine['promises'] = [] + + const postMessage = async >( + engineId: number, + action: T, + ...params: U['params'] + ) => { + if (action === 'setSituation') { + setSituationVersion((situationVersion) => situationVersion + 1) + } + + const warning = setTimeout(() => { + console.log('promise waiting for too long, aborting!', action, params) + promises[id].reject?.(new Error('timeout')) + }, 5000) + + const id = promises.length + const promise = new Promise((resolve, reject) => { + promises[id] = { + resolve(...params: unknown[]) { + clearTimeout(warning) + + return resolve(...(params as Parameters)) + }, + reject(err) { + clearTimeout(warning) + + return reject(err) + }, + } + }) + + newWorker.postMessage({ engineId, action, params, id }) + + return promise + } + + newWorker.onmessage = function (e) { + console.log('msg:', e.data) + if ('error' in e.data) { + return promises[e.data.id].reject?.(e.data.error) + } + promises[e.data.id].resolve?.(e.data.result) + } + + const isWorkerReady = postMessage(0, 'init', { basename }) + + const workerEngine = { + worker: newWorker, + postMessage, + isWorkerReady, + promises, + } + + return workerEngine +} + +const sleepMs = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)) + +let worker: ReturnType | null = null + +/** + * This hook is used to create a worker engine. + * @param basename + */ +const useCreateWorkerEngine = (basename: ProviderProps['basename']) => { + const [situationVersion, setSituationVersion] = useState(0) + + useEffect(() => { + worker = initWorkerEngine(basename, setSituationVersion) + + console.time('init') + void worker.isWorkerReady.then(() => console.timeEnd('init')) + + // example of usage + void Promise.all([ + asyncEvaluate('SMIC').then((result) => console.log('result', result)), + asyncEvaluate('date').then((result) => console.log('result', result)), + ]) + + return () => { + console.log('worker terminated!') + + worker?.worker.terminate() + worker?.promises.forEach((promise) => + promise.reject?.('worker terminated') + ) + worker = null + } + }, [basename]) + + return situationVersion +} + +// asynchronous setSituation function + +/** + * This function is used to set the situation in the worker with a specific engineId. + */ +export const asyncSetSituationWithEngineId = async ( + engineId: number, + ...params: Action<'setSituation'>['params'] +): Promise['result']> => { + if (!worker) { + await sleepMs(10) + + return asyncSetSituationWithEngineId(engineId, ...params) + } + + return await worker.postMessage(engineId, 'setSituation', ...params) +} + +/** + * * This function is used to set the situation in the worker. + */ +export const asyncSetSituation = async ( + ...params: Action<'setSituation'>['params'] +) => asyncSetSituationWithEngineId(0, ...params) + +// asynchronous evaluate function + +/** + * This function is used to evaluate a publicodes expression in the worker with a specific engineId. + */ +export const asyncEvaluateWithEngineId = async ( + engineId: number, + ...params: Action<'evaluate'>['params'] +): Promise['result']> => { + if (!worker) { + await sleepMs(10) + + return asyncEvaluateWithEngineId(engineId, ...params) + } + + return await worker.postMessage(engineId, 'evaluate', ...params) +} + +/** + * This function is used to evaluate a publicodes expression in the worker. + */ +export const asyncEvaluate = async (...params: Action<'evaluate'>['params']) => + asyncEvaluateWithEngineId(0, ...params) + +/** + * This hook is used to evaluate a publicodes expression in the worker. + * @param defaultValue + */ +// export const useAsyncEvaluate = ( +// defaultValue?: T +// ) => { +// const [response, setResponse] = useState['result'] | T>( +// defaultValue as T +// ) + +// const evaluate = useCallback(async (value: PublicodesExpression) => { +// const result = await asyncEvaluate(value) +// setResponse(result) + +// return result +// }, []) + +// return [response, evaluate] as const +// } + +// asynchronous getRule function: + +/** + * This function is used to get a publicodes rule that is in the worker with a specific EngineId. + */ +export const asyncGetRuleWithEngineId = async ( + engineId: number, + ...params: Action<'getRule'>['params'] +): Promise['result']> => { + if (!worker) { + await sleepMs(10) + + return asyncGetRuleWithEngineId(engineId, ...params) + } + + return await worker.postMessage(engineId, 'getRule', ...params) +} + +/** + * This function is used to get a rule in the worker. + */ +export const asyncGetRule = async (...params: Action<'getRule'>['params']) => + asyncGetRuleWithEngineId(0, ...params) + +/** + * This hook is used to get a rule in the worker. + * @param defaultValue + */ +export const useAsyncGetRule = < + Names extends DottedName, + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-constraint + T extends unknown = undefined +>( + dottedName: Names, + defaultValue?: T +) => + usePromiseOnSituationChange( + () => asyncGetRule(dottedName), + [dottedName], + defaultValue + ) + +// asynchronous getParsedRules function + +/** + * This function is used to get all the parsed rules in the worker with a specific engineId. + */ +export const asyncGetParsedRulesWithEngineId = async ( + engineId: number +): Promise['result']> => { + if (!worker) { + await sleepMs(10) + + return asyncGetParsedRulesWithEngineId(engineId) + } + + return await worker.postMessage(engineId, 'getParsedRules') +} + +/** + * This function is used to get all the parsed rules in the worker. + */ +export const asyncGetParsedRules = async () => + asyncGetParsedRulesWithEngineId(0) + +/** + * + */ +export const useParsedRules = (engineId = 0) => + usePromiseOnSituationChange( + async () => await asyncGetParsedRulesWithEngineId(engineId), + [engineId] + ) + +// asynchronous shallowCopy function + +/** + * This function is used to shallow copy an engine in the worker with a specific engineId. + */ +export const asyncShallowCopyWithEngineId = async ( + engineId: number +): Promise['result']> => { + if (!worker) { + await sleepMs(10) + + return asyncShallowCopyWithEngineId(engineId) + } + + return await worker.postMessage(engineId, 'shallowCopy') +} + +/** + * This function is used to shallow copy an engine in the worker. + */ +export const asyncShallowCopy = async () => asyncShallowCopyWithEngineId(0) + +// asynchronous deleteShallowCopy function + +/** + * * This function is used to delete a shallow copy of an engine in the worker. + */ +export const asyncDeleteShallowCopy = async ( + engineId: number +): Promise['result']> => { + if (!worker) { + await sleepMs(10) + + return asyncDeleteShallowCopy(engineId) + } + + return await worker.postMessage(0, 'deleteShallowCopy', { engineId }) +} + +const SituationUpdated = React.createContext(0) + +export const SituationUpdatedProvider = ({ + children, + basename, +}: { + children: React.ReactNode + basename: ProviderProps['basename'] +}) => { + const situationVersion = useCreateWorkerEngine(basename) + + return ( + + {children} + + ) +} + +export const useSituationUpdated = () => { + const situationVersion = useContext(SituationUpdated) + + return situationVersion +} + +/** + * Wrapper around usePromise that adds the situation version to the dependencies + * @example const date = usePromiseOnSituationChange(() => asyncEvaluate('date'), []) // date will be updated when the situation changes + */ +export const usePromiseOnSituationChange = ( + promise: () => Promise, + deps: DependencyList, + defaultValue?: Default +): T | Default => { + const situationVersion = useSituationUpdated() + + // eslint-disable-next-line react-hooks/exhaustive-deps + const state = usePromise(promise, [...deps, situationVersion], defaultValue) + + return state +}