Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions packages/apollo-wind/.storybook/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,17 @@ const config: StorybookConfig = {
: undefined,

async viteFinal(config) {
// Enable React Compiler in Storybook so react-scan reflects real perf
const react = await import('@vitejs/plugin-react');
config.plugins ??= [];
config.plugins.push(
react.default({
babel: {
plugins: [['babel-plugin-react-compiler', { target: '18' }]],
},
})
);

return {
...config,
resolve: {
Expand Down
4 changes: 4 additions & 0 deletions packages/apollo-wind/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@
"jsep": "^1.4.0",
"lucide-react": "^0.555.0",
"next-themes": "^0.4.6",
"react-compiler-runtime": "^1.0.0",
"react-day-picker": "^9.13.0",
"react-hook-form": "^7.66.1",
"react-resizable-panels": "^3.0.6",
Expand All @@ -96,6 +97,7 @@
"zod": "^4.3.5"
},
"devDependencies": {
"@rsbuild/plugin-babel": "^1.0.6",
"@rsbuild/plugin-react": "^1.4.1",
"@rslib/core": "^0.17.0",
"@semantic-release/changelog": "^6.0.3",
Expand All @@ -113,10 +115,12 @@
"@types/node": "^24.10.1",
"@types/react": "^19.2.6",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^4.7.0",
"@vitest/coverage-v8": "^4.0.14",
"@vitest/ui": "^4.0.14",
"ajv": "^8.17.1",
"autoprefixer": "^10.4.22",
"babel-plugin-react-compiler": "1.0.0",
"globals": "^16.5.0",
"jest-axe": "^10.0.0",
"jsdom": "^27.2.0",
Expand Down
15 changes: 14 additions & 1 deletion packages/apollo-wind/rslib.config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { pluginBabel } from '@rsbuild/plugin-babel';
import { pluginReact } from '@rsbuild/plugin-react';
import type { RslibConfig } from '@rslib/core';
import { defineConfig } from '@rslib/core';
Expand Down Expand Up @@ -56,7 +57,19 @@ export default defineConfig({
'postcss.config.export': './postcss.config.export.js',
},
},
plugins: [pluginReact()],
plugins: [
pluginReact(),
pluginBabel({
include: /\.(?:jsx?|tsx?)$/,
babelLoaderOptions(opts) {
if (!opts.plugins) {
opts.plugins = [];
}
opts.plugins.unshift(['babel-plugin-react-compiler', { target: '18' }]);
return opts;
},
}),
],
output: {
target: 'web',
cleanDistPath: true,
Expand Down
96 changes: 50 additions & 46 deletions packages/apollo-wind/src/components/forms/form-designer.tsx
Original file line number Diff line number Diff line change
@@ -1,58 +1,58 @@
import { useState, useMemo, useEffect } from 'react';
import type {
FormSchema,
FieldMetadata,
FieldRule,
FieldCondition,
DataSource,
FieldType,
FormPlugin,
ValidationConfig,
} from './form-schema';
import { schemaToJson } from './schema-serializer';
import { MetadataForm } from './metadata-form';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Checkbox } from '@/components/ui/checkbox';
import { Switch } from '@/components/ui/switch';
import {
Trash2,
Plus,
MoveUp,
MoveDown,
Eye,
EyeOff,
AlertTriangle,
Asterisk,
Ban,
ChevronRight,
Code,
Database,
Eye,
EyeOff,
GitBranch,
Layers,
ChevronRight,
GripVertical,
Layers,
MoveDown,
MoveUp,
Plus,
Settings,
AlertTriangle,
Asterisk,
Ban,
Trash2,
View,
} from 'lucide-react';
import { Separator } from '@/components/ui/separator';
import { useEffect, useMemo, useRef, useState } from 'react';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Grid } from '@/components/ui/layout/grid';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import { Switch } from '@/components/ui/switch';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea';
import type {
DataSource,
FieldCondition,
FieldMetadata,
FieldRule,
FieldType,
FormPlugin,
FormSchema,
ValidationConfig,
} from './form-schema';
import { MetadataForm } from './metadata-form';
import { schemaToJson } from './schema-serializer';

/**
* Enhanced Form Designer Component
Expand Down Expand Up @@ -669,7 +669,11 @@ function FieldConfigForm({ field, onUpdate, allFields, existingFieldNames }: Fie
const schema = useMemo(() => createFieldConfigSchema(field), [field]);

// Track current type for detecting type changes
const currentTypeRef = useMemo(() => ({ type: field.type }), [field.type]);
const currentTypeRef = useRef(field.type);

useEffect(() => {
currentTypeRef.current = field.type;
}, [field.type]);

// Sync plugin - updates parent on any value change
const plugins = useMemo<FormPlugin[]>(
Expand Down Expand Up @@ -734,7 +738,7 @@ function FieldConfigForm({ field, onUpdate, allFields, existingFieldNames }: Fie
}

// Clear properties from previous type if type changed
if (newType !== currentTypeRef.type) {
if (newType !== currentTypeRef.current) {
// Preserve requiredMessage across type changes
const preservedRequiredMessage = field.validation?.requiredMessage;

Expand Down Expand Up @@ -781,14 +785,14 @@ function FieldConfigForm({ field, onUpdate, allFields, existingFieldNames }: Fie
};
}
}
currentTypeRef.type = newType;
currentTypeRef.current = newType;
}

onUpdate(updates);
},
},
],
[onUpdate, currentTypeRef, field.validation?.requiredMessage]
[onUpdate, field.validation?.requiredMessage]
);

// Check if field type needs options editor
Expand Down Expand Up @@ -1692,7 +1696,7 @@ function RulesEditor({
// For numeric fields, try to parse as number
if (isNumericField) {
const num = Number(val);
if (!isNaN(num)) return num;
if (!Number.isNaN(num)) return num;
}

// For text-based fields, keep as string
Expand Down Expand Up @@ -1988,7 +1992,7 @@ function RulesEditor({
type="button"
className={`flex items-center gap-2 p-2 rounded-lg border text-left text-sm transition-all ${
selectedEffect === effect.value
? effect.color + ' border-current'
? `${effect.color} border-current`
: 'hover:bg-muted'
}`}
onClick={() => setSelectedEffect(effect.value)}
Expand Down
60 changes: 29 additions & 31 deletions packages/apollo-wind/src/components/forms/metadata-form.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
import { standardSchemaResolver } from '@hookform/resolvers/standard-schema';
import React, { useEffect, useMemo, useRef, useState } from 'react';

import { FormProvider, useForm } from 'react-hook-form';
import { z } from 'zod/v4';

import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import { Button } from '@/components/ui/button';
import { standardSchemaResolver } from '@hookform/resolvers/standard-schema';

import { DataFetcher } from './data-fetcher';
import { FormFieldRenderer } from './field-renderer';
Expand Down Expand Up @@ -72,19 +70,19 @@ export function MetadataForm({
const { watch, handleSubmit, reset } = form;

// Use ref to store values - prevents context recreation on every render
const valuesRef = useRef<Record<string, unknown>>({});
// This watch() triggers re-renders but we store in ref for stable context access
const watchedValues = watch();
valuesRef.current = watchedValues;
const valuesRef = useRef<Record<string, unknown>>(form.getValues());
// Trigger re-renders on value changes so conditional sections/actions re-evaluate.
// The actual ref sync happens in the watch subscription effect below.
watch();

// Build form context - STABLE reference (values accessed via ref/getters)
const context: FormContext = useMemo(
() => ({
schema,
form,
// Use getter to always return latest values without recreating context
// Use getter so render-time reads always see current form values
get values() {
return valuesRef.current;
return form.getValues();
},
get errors() {
return form.formState.errors;
Expand All @@ -98,7 +96,7 @@ export function MetadataForm({
currentStep: schema.steps ? currentStep : undefined,

evaluateConditions: (conditions: FieldCondition[]) =>
RulesEngine.evaluateConditions(conditions, valuesRef.current, 'AND'),
RulesEngine.evaluateConditions(conditions, form.getValues(), 'AND'),

fetchData: async (source: DataSource) => {
const result = await DataFetcher.fetch(source, valuesRef.current);
Expand All @@ -117,7 +115,27 @@ export function MetadataForm({

// Ref for context to use in useEffects without causing dependency loops
const contextRef = useRef(context);
contextRef.current = context;

useEffect(() => {
contextRef.current = context;
}, [context]);

// Watch for value changes — declared BEFORE init effect so the subscription
// catches reset() during initialization. Keeps valuesRef in sync for async
// callbacks (fetchData, plugins). Plugin callbacks are gated by isInitialized.
useEffect(() => {
const subscription = watch((value, { name }) => {
valuesRef.current = value as Record<string, unknown>;

if (isInitialized && name) {
plugins.forEach((plugin) => {
plugin.onValueChange?.(name, value[name], contextRef.current);
});
}
});

return () => subscription.unsubscribe();
}, [watch, isInitialized, plugins]);

// Initialize form - runs once on mount only
// biome-ignore lint/correctness/useExhaustiveDependencies: intentionally runs once on mount - use key prop to reinitialize with new schema
Expand All @@ -143,26 +161,6 @@ export function MetadataForm({
initializeForm();
}, [isInitialized]);

// Watch for field changes and execute plugin hooks
useEffect(() => {
if (!isInitialized) return;

const subscription = watch((value, { name }) => {
if (name) {
// Update valuesRef with the latest values BEFORE calling plugins
// This ensures context.values returns current data, not stale data
valuesRef.current = value as Record<string, unknown>;

// Plugin field change hooks
plugins.forEach((plugin) => {
plugin.onValueChange?.(name, value[name], contextRef.current);
});
}
});

return () => subscription.unsubscribe();
}, [watch, isInitialized, plugins]);

// Handle form submission
const handleFormSubmit = handleSubmit(async (data) => {
// Plugin submit hooks
Expand Down
Loading
Loading