diff --git a/app/assets/stylesheets/01_common.scss b/app/assets/stylesheets/01_common.scss
index 00c777a0c..47b94bc1d 100644
--- a/app/assets/stylesheets/01_common.scss
+++ b/app/assets/stylesheets/01_common.scss
@@ -28,3 +28,7 @@ body {
.container {
@extend %container;
}
+
+react-fragment {
+ display: block;
+}
diff --git a/app/assets/stylesheets/carte.scss b/app/assets/stylesheets/carte.scss
index ec8dd3c48..3de591ed2 100644
--- a/app/assets/stylesheets/carte.scss
+++ b/app/assets/stylesheets/carte.scss
@@ -10,7 +10,7 @@
}
}
-.form [data-react-component-value='MapEditor'] [data-reach-combobox-input] {
+.form react-fragment[data-component-name='MapEditor'] [data-reach-combobox-input] {
margin-bottom: 0;
}
diff --git a/app/assets/stylesheets/forms.scss b/app/assets/stylesheets/forms.scss
index 7b0f484cf..3b144f941 100644
--- a/app/assets/stylesheets/forms.scss
+++ b/app/assets/stylesheets/forms.scss
@@ -524,7 +524,7 @@
}
}
-[data-react-component-value^="ComboMultiple"] {
+react-fragment[data-component-name^="ComboMultiple"] {
margin-bottom: $default-fields-spacer;
[data-reach-combobox-input] {
diff --git a/app/assets/stylesheets/personnes_impliquees.scss b/app/assets/stylesheets/personnes_impliquees.scss
index d47aa2755..990876d80 100644
--- a/app/assets/stylesheets/personnes_impliquees.scss
+++ b/app/assets/stylesheets/personnes_impliquees.scss
@@ -9,7 +9,7 @@
margin-left: 16px;
}
- [data-react-component-value^="ComboMultiple"] {
+ react-fragment[data-component-name^="ComboMultiple"] {
margin-bottom: 0;
[data-reach-combobox-token-list] {
diff --git a/app/assets/stylesheets/procedure_show.scss b/app/assets/stylesheets/procedure_show.scss
index 5100162cb..8b27c3436 100644
--- a/app/assets/stylesheets/procedure_show.scss
+++ b/app/assets/stylesheets/procedure_show.scss
@@ -45,7 +45,7 @@
display: inline-block;
}
- [data-react-component-value^="ComboMultiple"] {
+ react-fragment[data-component-name^="ComboMultiple"] {
margin-bottom: $default-fields-spacer;
[data-reach-combobox-token-list] {
diff --git a/app/components/react_component.rb b/app/components/react_component.rb
new file mode 100644
index 000000000..0f643d5ec
--- /dev/null
+++ b/app/components/react_component.rb
@@ -0,0 +1,14 @@
+class ReactComponent < ApplicationComponent
+ erb_template <<-ERB
+ <% if content? %>
+ props="<%= @props.to_json %>"><%= content %>
+ <% else %>
+ props="<%= @props.to_json %>">
+ <% end %>
+ ERB
+
+ def initialize(name, **props)
+ @name = name
+ @props = props
+ end
+end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index f5f859032..856cc923e 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -59,10 +59,6 @@ module ApplicationHelper
'alert'
end
- def react_component(name, props = {}, html = {})
- tag.div(**html.merge(data: { controller: 'react', react_component_value: name, react_props_value: props.to_json }))
- end
-
def current_email
current_user&.email ||
current_instructeur&.email ||
diff --git a/app/javascript/components/ComboMultiple.tsx b/app/javascript/components/ComboMultiple.tsx
index 713e64a8c..a4206b9f8 100644
--- a/app/javascript/components/ComboMultiple.tsx
+++ b/app/javascript/components/ComboMultiple.tsx
@@ -1,4 +1,4 @@
-import React, {
+import {
useMemo,
useState,
useRef,
diff --git a/app/javascript/components/Layout.tsx b/app/javascript/components/Layout.tsx
new file mode 100644
index 000000000..39a33aa9e
--- /dev/null
+++ b/app/javascript/components/Layout.tsx
@@ -0,0 +1,12 @@
+import { I18nProvider } from 'react-aria-components';
+import { StrictMode, type ReactNode } from 'react';
+
+export function Layout({ children }: { children: ReactNode }) {
+ const locale = document.documentElement.lang;
+ console.debug(`locale: ${locale}`);
+ return (
+
+ {children}
+
+ );
+}
diff --git a/app/javascript/components/MapEditor/components/ImportFileInput.tsx b/app/javascript/components/MapEditor/components/ImportFileInput.tsx
index 68be216e2..28122dba5 100644
--- a/app/javascript/components/MapEditor/components/ImportFileInput.tsx
+++ b/app/javascript/components/MapEditor/components/ImportFileInput.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useCallback, MouseEvent, ChangeEvent } from 'react';
+import { useState, useCallback, MouseEvent, ChangeEvent } from 'react';
import type { FeatureCollection } from 'geojson';
import invariant from 'tiny-invariant';
diff --git a/app/javascript/components/MapEditor/components/PointInput.tsx b/app/javascript/components/MapEditor/components/PointInput.tsx
index 4f2fb4ead..5cd2e342f 100644
--- a/app/javascript/components/MapEditor/components/PointInput.tsx
+++ b/app/javascript/components/MapEditor/components/PointInput.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useId } from 'react';
+import { useState, useId } from 'react';
import { fire } from '@utils';
import type { Feature, FeatureCollection } from 'geojson';
import CoordinateInput from 'react-coordinate-input';
diff --git a/app/javascript/components/MapReader/components/GeoJSONLayer.tsx b/app/javascript/components/MapReader/components/GeoJSONLayer.tsx
index add1dca03..807730cb3 100644
--- a/app/javascript/components/MapReader/components/GeoJSONLayer.tsx
+++ b/app/javascript/components/MapReader/components/GeoJSONLayer.tsx
@@ -1,4 +1,4 @@
-import React, { useCallback, useEffect, useMemo } from 'react';
+import { useCallback, useEffect, useMemo } from 'react';
import { Popup, LngLatBoundsLike, LngLatLike } from 'maplibre-gl';
import type { Feature, FeatureCollection, Point } from 'geojson';
diff --git a/app/javascript/components/MapReader/index.tsx b/app/javascript/components/MapReader/index.tsx
index 1f9fb07d4..a8662f152 100644
--- a/app/javascript/components/MapReader/index.tsx
+++ b/app/javascript/components/MapReader/index.tsx
@@ -1,4 +1,3 @@
-import React from 'react';
import 'maplibre-gl/dist/maplibre-gl.css';
import type { FeatureCollection } from 'geojson';
diff --git a/app/javascript/components/shared/FlashMessage.tsx b/app/javascript/components/shared/FlashMessage.tsx
index 4b358df3c..964a58d56 100644
--- a/app/javascript/components/shared/FlashMessage.tsx
+++ b/app/javascript/components/shared/FlashMessage.tsx
@@ -1,4 +1,3 @@
-import React from 'react';
import { createPortal } from 'react-dom';
import invariant from 'tiny-invariant';
diff --git a/app/javascript/components/shared/maplibre/MapLibre.tsx b/app/javascript/components/shared/maplibre/MapLibre.tsx
index b2045b6d0..d160775f8 100644
--- a/app/javascript/components/shared/maplibre/MapLibre.tsx
+++ b/app/javascript/components/shared/maplibre/MapLibre.tsx
@@ -1,4 +1,4 @@
-import React, {
+import {
useState,
useContext,
useRef,
diff --git a/app/javascript/components/shared/maplibre/StyleControl.tsx b/app/javascript/components/shared/maplibre/StyleControl.tsx
index ce83b75c1..afd46345c 100644
--- a/app/javascript/components/shared/maplibre/StyleControl.tsx
+++ b/app/javascript/components/shared/maplibre/StyleControl.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useId } from 'react';
+import { useState, useId } from 'react';
import { Popover, RadioGroup } from '@headlessui/react';
import { usePopper } from 'react-popper';
import { MapIcon } from '@heroicons/react/outline';
diff --git a/app/javascript/controllers/react_controller.tsx b/app/javascript/controllers/react_controller.tsx
deleted file mode 100644
index cdaff89de..000000000
--- a/app/javascript/controllers/react_controller.tsx
+++ /dev/null
@@ -1,72 +0,0 @@
-import { Controller } from '@hotwired/stimulus';
-import React, { lazy, Suspense, FunctionComponent } from 'react';
-import { render, unmountComponentAtNode } from 'react-dom';
-import invariant from 'tiny-invariant';
-
-type Props = Record;
-type Loader = () => Promise<{ default: FunctionComponent }>;
-const componentsRegistry = new Map>();
-const components = import.meta.glob('../components/*.tsx');
-
-for (const [path, loader] of Object.entries(components)) {
- const [filename] = path.split('/').reverse();
- const componentClassName = filename.replace(/\.(ts|tsx)$/, '');
- console.debug(
- `Registered lazy default export for "${componentClassName}" component`
- );
- componentsRegistry.set(
- componentClassName,
- LoadableComponent(loader as Loader)
- );
-}
-
-// Initialize React components when their markup appears into the DOM.
-//
-// Example:
-//
-//
-export class ReactController extends Controller {
- static values = {
- component: String,
- props: Object
- };
-
- declare readonly componentValue: string;
- declare readonly propsValue: Props;
-
- connect(): void {
- this.mountComponent(this.element as HTMLElement);
- }
-
- disconnect(): void {
- unmountComponentAtNode(this.element as HTMLElement);
- }
-
- private mountComponent(node: HTMLElement): void {
- const componentName = this.componentValue;
- const props = this.propsValue;
- const Component = this.getComponent(componentName);
-
- invariant(
- Component,
- `Cannot find a React component with class "${componentName}"`
- );
- render(, node);
- }
-
- private getComponent(componentName: string): FunctionComponent | null {
- return componentsRegistry.get(componentName) ?? null;
- }
-}
-
-const Spinner = () => ;
-
-function LoadableComponent(loader: Loader): FunctionComponent {
- const LazyComponent = lazy(loader);
- const Component: FunctionComponent = (props: Props) => (
- }>
-
-
- );
- return Component;
-}
diff --git a/app/javascript/controllers/turbo_controller.ts b/app/javascript/controllers/turbo_controller.ts
index 6c8374bc5..d210b3933 100644
--- a/app/javascript/controllers/turbo_controller.ts
+++ b/app/javascript/controllers/turbo_controller.ts
@@ -1,7 +1,9 @@
import { Actions } from '@coldwired/actions';
import { parseTurboStream } from '@coldwired/turbo-stream';
+import { createRoot, createReactPlugin, type Root } from '@coldwired/react';
import invariant from 'tiny-invariant';
import { session as TurboSession, type StreamElement } from '@hotwired/turbo';
+import type { ComponentType } from 'react';
import { ApplicationController } from './application_controller';
@@ -20,6 +22,7 @@ export class TurboController extends ApplicationController {
#submitting = false;
#actions?: Actions;
+ #root?: Root;
// `actions` instrface exposes all available actions as methods and also `applyActions` method
// wich allows to apply a batch of actions. On top of regular `turbo-stream` actions we also
@@ -32,6 +35,17 @@ export class TurboController extends ApplicationController {
}
connect() {
+ this.#root = createRoot({
+ layoutComponentName: 'Layout/Layout',
+ loader,
+ schema: {
+ fragmentTagName: 'react-fragment',
+ componentTagName: 'react-component',
+ slotTagName: 'react-slot',
+ loadingClassName: 'loading'
+ }
+ });
+ const plugin = createReactPlugin(this.#root);
this.#actions = new Actions({
element: document.body,
schema: {
@@ -40,6 +54,7 @@ export class TurboController extends ApplicationController {
focusDirectionAttribute: 'data-turbo-focus-direction',
hiddenClassName: 'hidden'
},
+ plugins: [plugin],
debug: false
});
@@ -73,6 +88,11 @@ export class TurboController extends ApplicationController {
});
}
+ disconnect(): void {
+ this.#actions?.disconnect();
+ this.#root?.destroy();
+ }
+
private startSpinner() {
this.#submitting = true;
this.actions.show({ targets: this.spinnerTargets });
@@ -89,3 +109,24 @@ export class TurboController extends ApplicationController {
}
}
}
+
+type Loader = (exportName: string) => Promise>;
+const componentsRegistry: Record = {};
+const components = import.meta.glob('../components/*.tsx');
+
+const loader: Loader = (name) => {
+ const [moduleName, exportName] = name.split('/');
+ const loader = componentsRegistry[moduleName];
+ invariant(loader, `Cannot find a React component with name "${name}"`);
+ return loader(exportName ?? 'default');
+};
+
+for (const [path, loader] of Object.entries(components)) {
+ const [filename] = path.split('/').reverse();
+ const componentClassName = filename.replace(/\.(ts|tsx)$/, '');
+ console.debug(`Registered lazy export for "${componentClassName}" component`);
+ componentsRegistry[componentClassName] = (exportName) =>
+ loader().then(
+ (m) => (m as Record>)[exportName]
+ );
+}
diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml
index ceb84ff47..91fc4699e 100644
--- a/app/views/layouts/application.html.haml
+++ b/app/views/layouts/application.html.haml
@@ -59,7 +59,5 @@
- else
= render 'footer'
- - if Rails.env.development?
- = vite_typescript_tag 'axe-core'
= yield :charts_js
= render Attachment::ProgressBarComponent.new
diff --git a/app/views/manager/application/_javascript.html.erb b/app/views/manager/application/_javascript.html.erb
index 570441489..56f773546 100644
--- a/app/views/manager/application/_javascript.html.erb
+++ b/app/views/manager/application/_javascript.html.erb
@@ -11,6 +11,8 @@ by providing a `content_for(:javascript)` block.
<%= javascript_include_tag js_path %>
<% end %>
+<%= vite_client_tag %>
+<%= vite_react_refresh_tag %>
<%= vite_typescript_tag 'manager' %>
<%= yield :javascript %>
diff --git a/bun.lockb b/bun.lockb
index 7c777a111..608dba50f 100755
Binary files a/bun.lockb and b/bun.lockb differ
diff --git a/package.json b/package.json
index edad7ebc6..9f773fc21 100644
--- a/package.json
+++ b/package.json
@@ -1,9 +1,10 @@
{
"type": "module",
"dependencies": {
- "@coldwired/actions": "^0.11.2",
- "@coldwired/turbo-stream": "^0.11.1",
- "@coldwired/utils": "^0.11.4",
+ "@coldwired/actions": "^0.13.0",
+ "@coldwired/react": "^0.15.0",
+ "@coldwired/turbo-stream": "^0.13.0",
+ "@coldwired/utils": "^0.13.0",
"@frsource/autoresize-textarea": "^2.0.75",
"@gouvfr/dsfr": "^1.11.2",
"@graphiql/plugin-explorer": "^3.0.2",
@@ -17,7 +18,6 @@
"@rails/actiontext": "^7.1.3-2",
"@rails/activestorage": "^7.1.3-2",
"@rails/ujs": "^7.1.3-2",
- "@reach/combobox": "^0.17.0",
"@reach/slider": "^0.17.0",
"@sentry/browser": "8.7.0",
"@tiptap/core": "^2.2.4",
@@ -50,31 +50,32 @@
"graphiql": "^3.2.3",
"graphql": "^16.8.1",
"highcharts": "^10.3.3",
- "is-hotkey": "^0.2.0",
"lightgallery": "^2.7.2",
"maplibre-gl": "^1.15.2",
"match-sorter": "^6.3.4",
"patch-package": "^8.0.0",
- "react": "^18.2.0",
+ "react": "^18.3.0",
+ "react-aria-components": "^1.2.0",
"react-coordinate-input": "^1.0.0",
- "react-dom": "^18.2.0",
+ "react-dom": "^18.3.0",
"react-popper": "^2.3.0",
- "react-query": "^3.39.3",
+ "react-use-event-hook": "^0.9.6",
"spectaql": "^2.3.1",
"stimulus-use": "^0.52.2",
"terser": "^5.31.0",
"tiny-invariant": "^1.3.3",
"tippy.js": "^6.3.7",
"trix": "^1.2.3",
- "use-debounce": "^9.0.4",
+ "usehooks-ts": "^3.1.0",
"zod": "^3.20.2"
},
"devDependencies": {
"@esbuild/darwin-arm64": "=0.19.9",
"@esbuild/linux-x64": "=0.19.9",
"@esbuild/win32-x64": "=0.19.9",
- "@rollup/rollup-linux-x64-gnu": "=4.9.1",
+ "@react-aria/optimize-locales-plugin": "^1.1.0",
"@rollup/rollup-darwin-arm64": "=4.9.1",
+ "@rollup/rollup-linux-x64-gnu": "=4.9.1",
"@rollup/rollup-win32-x64-msvc": "=4.9.1",
"@types/debounce": "^1.2.4",
"@types/geojson": "^7946.0.14",
@@ -82,8 +83,8 @@
"@types/mapbox__mapbox-gl-draw": "^1.2.5",
"@types/rails__activestorage": "^7.1.1",
"@types/rails__ujs": "^6.0.4",
- "@types/react": "^17.0.43",
- "@types/react-dom": "^17.0.14",
+ "@types/react": "^18.2.79",
+ "@types/react-dom": "^18.2.25",
"@types/sortablejs": "^1.15.8",
"@typescript-eslint/eslint-plugin": "^7.11.0",
"@typescript-eslint/parser": "^7.11.0",
@@ -169,6 +170,7 @@
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react-hooks/recommended",
+ "plugin:react/jsx-runtime",
"prettier"
],
"rules": {
diff --git a/tsconfig.json b/tsconfig.json
index dc38473b9..4240d0dc7 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -5,7 +5,7 @@
"target": "ES2019",
"moduleResolution": "node",
"module": "es2020",
- "jsx": "react",
+ "jsx": "react-jsx",
"esModuleInterop": true,
"experimentalDecorators": true,
"isolatedModules": true,
@@ -13,7 +13,7 @@
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
- "types": ["react/next", "react-dom/next", "vite/client"],
+ "types": ["vite/client"],
"paths": {
"~/*": ["./app/javascript/*"],
"@utils": ["./app/javascript/shared/utils.ts"]
diff --git a/vite.config.ts b/vite.config.ts
index 036c8e0c8..f0f0dfde8 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -2,14 +2,21 @@ import { defineConfig } from 'vite';
import ViteReact from '@vitejs/plugin-react';
import RubyPlugin from 'vite-plugin-ruby';
import FullReload from 'vite-plugin-full-reload';
+import optimizeLocales from '@react-aria/optimize-locales-plugin';
const plugins = [
RubyPlugin(),
- ViteReact({ jsxRuntime: 'classic' }),
+ ViteReact(),
FullReload(
['config/routes.rb', 'app/views/**/*', 'app/components/**/*.haml'],
{ delay: 200 }
- )
+ ),
+ {
+ ...optimizeLocales.vite({
+ locales: ['en-GB', 'fr-FR']
+ }),
+ enforce: 'pre' as const
+ }
];
export default defineConfig({