@classytic/formkit 1.3.1 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +73 -0
- package/README.md +166 -20
- package/dist/index.d.mts +577 -164
- package/dist/index.mjs +1131 -294
- package/dist/server.d.mts +472 -151
- package/dist/server.mjs +593 -98
- package/package.json +116 -113
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,79 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.5.0] - 2026-07-02
|
|
9
|
+
|
|
10
|
+
Stability / hardening release. Two intentional **behavioral changes** are called
|
|
11
|
+
out below — read them before upgrading.
|
|
12
|
+
|
|
13
|
+
### Behavioral changes
|
|
14
|
+
|
|
15
|
+
- **`field.number()` no longer injects `min: 0`.** The builder previously added
|
|
16
|
+
an implicit non-negative constraint; forms relying on it must now pass
|
|
17
|
+
`{ min: 0 }` explicitly. Signed quantities (deltas, temperatures) are valid by
|
|
18
|
+
default.
|
|
19
|
+
- **Missing components render a visible placeholder in production** (previously
|
|
20
|
+
a silent null). The wrong-widget `text` fallback for unregistered types was
|
|
21
|
+
removed entirely. Use the new `onMissingComponent` provider callback for
|
|
22
|
+
telemetry. Exception: `hidden` fields with no registered component render
|
|
23
|
+
nothing by design.
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- `validateSchema(schema)` — structural schema linting (missing name/type,
|
|
28
|
+
duplicate names, `itemFields` on non-containers, unknown DSL operators),
|
|
29
|
+
exported from both entries and auto-run in dev by `FormGenerator`.
|
|
30
|
+
- `focusFirstError(root?)` — focuses the first visibly-invalid field in layout
|
|
31
|
+
order; retries across two animation frames so it works from
|
|
32
|
+
`handleSubmit(onValid, () => focusFirstError())` on the very first submit.
|
|
33
|
+
- `loadOptions` now receives `{ signal: AbortSignal }` as a second argument;
|
|
34
|
+
superseded/unmounted loads are aborted. New opt-in `cacheOptions` memoizes
|
|
35
|
+
results per watched-value signature (bounded).
|
|
36
|
+
- `onMissingComponent` callback on `FormSystemProvider`.
|
|
37
|
+
- `colSpan` field prop (surfaced as `data-col-span`; `fullWidth` now also emits
|
|
38
|
+
an inline `grid-column: 1 / -1` so full-width layout works without scanning
|
|
39
|
+
the package with Tailwind).
|
|
40
|
+
- `resetOnSchemaChange` option on `useFormKit` for DB-loaded schema swaps.
|
|
41
|
+
- Dev warning when `loadOptions` is used without `watchNames` (whole-form
|
|
42
|
+
refetch storms).
|
|
43
|
+
- `buildFieldDefaults` exported; array-item seeds are now type-aware (`false`
|
|
44
|
+
for checkbox/switch, `[]` for multiselect/tags, `undefined` for number).
|
|
45
|
+
- `mergeDefaultValues(base, override)` exported (both entries) — the exact
|
|
46
|
+
Date-safe deep merge `useFormKit` applies between schema defaults and caller
|
|
47
|
+
`defaultValues`, for wrappers that re-seed via `form.reset(...)`.
|
|
48
|
+
|
|
49
|
+
### Fixed
|
|
50
|
+
|
|
51
|
+
- **Rules-of-hooks crash**: `useFieldComponent` and the field-props memo are
|
|
52
|
+
hoisted above the group/array fallback returns, so a registry that changes
|
|
53
|
+
after mount (code-split adapters) can no longer change the hook count.
|
|
54
|
+
`eslint-plugin-react-hooks` is now enforced in CI-lint.
|
|
55
|
+
- **Date / class-instance defaults survive merging**: `useFormKit`'s deep-merge
|
|
56
|
+
now replaces non-plain objects (Date, File, class instances) wholesale
|
|
57
|
+
instead of spread-merging them into `{}`.
|
|
58
|
+
- Function conditions with `watchNames` receive properly **nested** watched
|
|
59
|
+
values (`(v) => v.address.city` works; previously flat `"address.city"` keys
|
|
60
|
+
made them throw and hide the field).
|
|
61
|
+
- `extractDefaultValues` recurses into nested groups (deep defaults were
|
|
62
|
+
dropped beyond one level); shares one walker with the async variant.
|
|
63
|
+
- Fallback UI (array items, error boundary, missing-component placeholder) uses
|
|
64
|
+
structural inline styles + `currentColor` instead of Tailwind classes that
|
|
65
|
+
don't exist in consumer builds; `formkit-*` classes remain as theming hooks.
|
|
66
|
+
- `process.env` reads are guarded (`isDev()` helper), so bundler-less ESM
|
|
67
|
+
environments no longer throw `ReferenceError`.
|
|
68
|
+
|
|
69
|
+
## [1.4.0] - 2026-05-26
|
|
70
|
+
|
|
71
|
+
### Added
|
|
72
|
+
|
|
73
|
+
- New schema helpers exported from `./schema`: `extractDefaultValuesAsync`, field-type predicates (`isChoiceField`, `isTextField`, `isNumericField`, `isDateField`, `isContainerField`, `isArrayField`, `isDynamicField`, `isConditionalField`), schema composition (`mergeSchemas`, `extendSection`, `pickFields`, `omitFields`, `flattenSchema`), and `applyServerErrors`.
|
|
74
|
+
- New exported types: `SectionRendererProps`, `GridRendererProps`, `FieldWrapperProps`, `ValidationRuleObject`, `PatternRuleObject`.
|
|
75
|
+
- Conditional package exports add `module-sync` condition for better React Server Components / sync-import interop.
|
|
76
|
+
|
|
77
|
+
### Changed
|
|
78
|
+
|
|
79
|
+
- `main` / `module` fields removed from `package.json` in favor of pure conditional `exports`.
|
|
80
|
+
|
|
8
81
|
## [1.3.0] - 2026-02-27
|
|
9
82
|
|
|
10
83
|
### Added
|
package/README.md
CHANGED
|
@@ -8,8 +8,12 @@ Headless, type-safe form generation engine for React 19. Schema-driven with full
|
|
|
8
8
|
|
|
9
9
|
## Features
|
|
10
10
|
|
|
11
|
-
- **
|
|
12
|
-
-
|
|
11
|
+
- **Headless** - Bring your own UI components (Shadcn, MUI, Chakra, etc.) via a
|
|
12
|
+
one-time component/layout adapter. Already using shadcn? Skip the adapter
|
|
13
|
+
entirely with the prebuilt one in **[`@classytic/fluid/formkit`](https://www.npmjs.com/package/@classytic/fluid)** —
|
|
14
|
+
its `SchemaForm` is genuinely a schema + `onSubmit` away.
|
|
15
|
+
- **Minimal boilerplate** - once an adapter is registered, `useFormKit` wires a
|
|
16
|
+
full form (defaults + RHF + generator props) in a few lines
|
|
13
17
|
- **Schema-driven** - Define forms with JSON/TypeScript schemas, defaults extracted automatically
|
|
14
18
|
- **Type-safe** - Full TypeScript support with generics
|
|
15
19
|
- **React Hook Form** - Built on top of the best form library, referentially stable return values
|
|
@@ -20,7 +24,7 @@ Headless, type-safe form generation engine for React 19. Schema-driven with full
|
|
|
20
24
|
- **Responsive layouts** - Multi-column grid layouts
|
|
21
25
|
- **Accessibility** - Auto-generated `fieldId`, `error`, and `fieldState` props
|
|
22
26
|
- **Validation helpers** - `buildValidationRules` generates RHF rules from schema props
|
|
23
|
-
- **Lightweight** - ~
|
|
27
|
+
- **Lightweight** - ~17KB gzipped (peer deps excluded, everything included), tree-shakeable
|
|
24
28
|
|
|
25
29
|
## Requirements
|
|
26
30
|
|
|
@@ -54,28 +58,41 @@ import { Label } from "@/components/ui/label";
|
|
|
54
58
|
|
|
55
59
|
export function FormInput({
|
|
56
60
|
control,
|
|
57
|
-
|
|
61
|
+
name,
|
|
62
|
+
rules, // pre-computed RHF rules from schema (required, minLength, pattern, validate…)
|
|
58
63
|
label,
|
|
59
64
|
placeholder,
|
|
60
65
|
required,
|
|
66
|
+
fieldId, // use as id on <input> and htmlFor on <Label>
|
|
67
|
+
errorId, // use as id on error <p> and aria-errormessage on <input>
|
|
68
|
+
shouldShowError, // true only after touch/submit — mirrors :user-invalid timing
|
|
61
69
|
error,
|
|
62
|
-
fieldId,
|
|
63
70
|
}: FieldComponentProps) {
|
|
64
71
|
return (
|
|
65
72
|
<Controller
|
|
66
|
-
name={
|
|
73
|
+
name={name}
|
|
67
74
|
control={control}
|
|
68
|
-
|
|
75
|
+
rules={rules}
|
|
76
|
+
render={({ field }) => (
|
|
69
77
|
<div className="space-y-2">
|
|
70
78
|
{label && (
|
|
71
79
|
<Label htmlFor={fieldId}>
|
|
72
80
|
{label}
|
|
73
|
-
{required && <span className="text-red-500 ml-1">*</span>}
|
|
81
|
+
{required && <span aria-hidden="true" className="text-red-500 ml-1">*</span>}
|
|
74
82
|
</Label>
|
|
75
83
|
)}
|
|
76
|
-
<Input
|
|
77
|
-
|
|
78
|
-
|
|
84
|
+
<Input
|
|
85
|
+
{...field}
|
|
86
|
+
id={fieldId}
|
|
87
|
+
placeholder={placeholder}
|
|
88
|
+
aria-required={required || undefined}
|
|
89
|
+
aria-invalid={shouldShowError || undefined}
|
|
90
|
+
aria-errormessage={shouldShowError ? errorId : undefined}
|
|
91
|
+
/>
|
|
92
|
+
{shouldShowError && (
|
|
93
|
+
<p id={errorId} role="alert" className="text-sm text-red-500">
|
|
94
|
+
{error?.message}
|
|
95
|
+
</p>
|
|
79
96
|
)}
|
|
80
97
|
</div>
|
|
81
98
|
)}
|
|
@@ -114,9 +131,16 @@ const layouts: LayoutRegistry = {
|
|
|
114
131
|
{children}
|
|
115
132
|
</div>
|
|
116
133
|
),
|
|
117
|
-
grid: ({ children, cols = 1 }) =>
|
|
118
|
-
|
|
119
|
-
|
|
134
|
+
grid: ({ children, cols = 1 }) => {
|
|
135
|
+
// Map to STATIC class names. A template like `grid-cols-${cols}` is never
|
|
136
|
+
// generated by Tailwind's JIT (it only sees whole class strings in source).
|
|
137
|
+
const COLS: Record<number, string> = {
|
|
138
|
+
1: "grid-cols-1",
|
|
139
|
+
2: "grid-cols-1 md:grid-cols-2",
|
|
140
|
+
3: "grid-cols-1 md:grid-cols-2 lg:grid-cols-3",
|
|
141
|
+
};
|
|
142
|
+
return <div className={`grid gap-4 ${COLS[cols] ?? "grid-cols-1"}`}>{children}</div>;
|
|
143
|
+
},
|
|
120
144
|
};
|
|
121
145
|
|
|
122
146
|
export function FormProvider({ children }: { children: React.ReactNode }) {
|
|
@@ -185,6 +209,26 @@ export default function SignupPage() {
|
|
|
185
209
|
}
|
|
186
210
|
```
|
|
187
211
|
|
|
212
|
+
## Type safety
|
|
213
|
+
|
|
214
|
+
Field **names** are enforced against your form type at *authoring* time by the
|
|
215
|
+
builders. Use `field.for<T>()`, `defineField<T>()`, or `section<T>()` and a typo
|
|
216
|
+
is a compile error:
|
|
217
|
+
|
|
218
|
+
```ts
|
|
219
|
+
const f = field.for<SignupData>();
|
|
220
|
+
f.text("emial", "Email"); // ✗ compile error — "emial" is not a key of SignupData
|
|
221
|
+
f.text("email", "Email"); // ✓
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
The `<FormGenerator schema>` / `useFormKit({ schema })` props themselves accept
|
|
225
|
+
`FormSchema<any>` **by design**: a schema's condition functions (`(values) => …`)
|
|
226
|
+
make `FormSchema<T>` contravariant in `T`, so pinning the prop to the form's `T`
|
|
227
|
+
would force a cast at every call site. Author with the typed builders for name
|
|
228
|
+
safety; the generator stays permissive so any built schema drops in without
|
|
229
|
+
ceremony. `itemFields` (group/array children) use relative names and are
|
|
230
|
+
resolved at render time, so they are typed loosely on purpose.
|
|
231
|
+
|
|
188
232
|
## API Reference
|
|
189
233
|
|
|
190
234
|
### useFormKit
|
|
@@ -203,6 +247,7 @@ const form = useFormKit({
|
|
|
203
247
|
disabled: false, // optional
|
|
204
248
|
variant: "compact", // optional
|
|
205
249
|
className: "my-form", // optional
|
|
250
|
+
resetOnSchemaChange: true, // optional — re-seed on schema swap
|
|
206
251
|
mode: "onBlur", // any useForm option
|
|
207
252
|
});
|
|
208
253
|
|
|
@@ -221,7 +266,9 @@ return (
|
|
|
221
266
|
);
|
|
222
267
|
```
|
|
223
268
|
|
|
224
|
-
Schema `defaultValue` fields are automatically extracted and merged with any explicit `defaultValues` you provide (explicit values take priority).
|
|
269
|
+
Schema `defaultValue` fields are automatically extracted and merged with any explicit `defaultValues` you provide (explicit values take priority). Nested/namespaced defaults are extracted as a nested object (`{ address: { city } }`), so react-hook-form's dot-path resolution applies them correctly.
|
|
270
|
+
|
|
271
|
+
Pass `resetOnSchemaChange: true` to re-seed the form when the `schema` identity changes at runtime (e.g. a schema loaded from a DB is swapped in). It's off by default because a reset discards in-progress edits.
|
|
225
272
|
|
|
226
273
|
`generatorProps` is memoized — it only recomputes when `schema`, `control`, `disabled`, `variant`, or `className` change.
|
|
227
274
|
|
|
@@ -277,7 +324,11 @@ interface Section<T> {
|
|
|
277
324
|
|
|
278
325
|
```ts
|
|
279
326
|
interface BaseField<T> {
|
|
280
|
-
name
|
|
327
|
+
// name accepts Path<T> or any string.
|
|
328
|
+
// Use field.for<T>() builders for call-site enforcement of valid paths.
|
|
329
|
+
// Relative names are correct for nameSpace sections and itemFields children —
|
|
330
|
+
// FormGenerator prefixes them at render time (e.g. "street" → "address.street").
|
|
331
|
+
name: Path<T> | string; // Field name (required)
|
|
281
332
|
type: FieldType; // Field type (required)
|
|
282
333
|
label?: string; // Field label
|
|
283
334
|
placeholder?: string; // Placeholder text
|
|
@@ -298,8 +349,10 @@ interface BaseField<T> {
|
|
|
298
349
|
loadOptions?: (formValues: Partial<T>) => Promise<FieldOption[]> | FieldOption[];
|
|
299
350
|
debounceMs?: number;
|
|
300
351
|
|
|
301
|
-
//
|
|
302
|
-
|
|
352
|
+
// Sub-fields for group and array types.
|
|
353
|
+
// Children use relative names ("street") — FormGenerator prefixes with parent name.
|
|
354
|
+
// Intentionally untyped to T because they resolve at render time, not authoring time.
|
|
355
|
+
itemFields?: BaseField[];
|
|
303
356
|
|
|
304
357
|
// For select/radio/checkbox
|
|
305
358
|
options?: FieldOption[];
|
|
@@ -342,9 +395,14 @@ interface FieldComponentProps<T extends FieldValues = FieldValues>
|
|
|
342
395
|
isDirty: boolean;
|
|
343
396
|
isTouched: boolean;
|
|
344
397
|
isValidating: boolean;
|
|
398
|
+
isSubmitted: boolean;
|
|
345
399
|
error?: FieldError;
|
|
346
400
|
};
|
|
347
401
|
fieldId: string; // Generated ID for label-input association (e.g. "formkit-field-email")
|
|
402
|
+
errorId: string; // ID for the error node — set as aria-errormessage on the input
|
|
403
|
+
shouldShowError: boolean; // true only after touch/submit (mirrors :user-invalid timing)
|
|
404
|
+
rules: RegisterOptions; // Pre-computed RHF rules from schema (required/min/max/pattern…)
|
|
405
|
+
isLoading?: boolean; // true while async loadOptions is in flight
|
|
348
406
|
}
|
|
349
407
|
```
|
|
350
408
|
|
|
@@ -447,6 +505,64 @@ function FormInput({ field, control, error, fieldId }: FieldComponentProps) {
|
|
|
447
505
|
|
|
448
506
|
Maps `required`, `min`, `max`, `minLength`, `maxLength`, and `pattern` from the field schema to RHF-compatible rules with auto-generated error messages.
|
|
449
507
|
|
|
508
|
+
## Validation & helpers
|
|
509
|
+
|
|
510
|
+
### validateSchema
|
|
511
|
+
|
|
512
|
+
Structurally validate a schema (great for DB-loaded / `resetOnSchemaChange`
|
|
513
|
+
schemas). Server-safe; returns `SchemaIssue[]` (empty ⇒ OK). `FormGenerator`
|
|
514
|
+
also runs it automatically in dev and `console.warn`s any issues.
|
|
515
|
+
|
|
516
|
+
```ts
|
|
517
|
+
import { validateSchema } from "@classytic/formkit"; // or /server
|
|
518
|
+
|
|
519
|
+
for (const issue of validateSchema(schema)) {
|
|
520
|
+
console.warn(`${issue.severity} ${issue.path}: ${issue.message}`);
|
|
521
|
+
}
|
|
522
|
+
// Detects: missing name/type, duplicate names (namespace-aware),
|
|
523
|
+
// itemFields on a non-container, empty containers, unknown DSL operators.
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
It validates **shape**, not your component registry — an unregistered field
|
|
527
|
+
`type` is a rendering concern, surfaced separately (see `onMissingComponent`).
|
|
528
|
+
|
|
529
|
+
### focusFirstError
|
|
530
|
+
|
|
531
|
+
Focus + scroll to the first visibly-invalid field, in layout order. Wire it into
|
|
532
|
+
the submit's invalid handler:
|
|
533
|
+
|
|
534
|
+
```tsx
|
|
535
|
+
import { focusFirstError } from "@classytic/formkit";
|
|
536
|
+
|
|
537
|
+
<form onSubmit={handleSubmit(onValid, () => focusFirstError())}>
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
React Hook Form calls the invalid handler *before* React commits the re-render
|
|
541
|
+
that marks fields invalid, so if the immediate query finds nothing,
|
|
542
|
+
`focusFirstError` automatically retries on the next two animation frames — the
|
|
543
|
+
plain wiring above works on the very first submit. It returns `true` when a
|
|
544
|
+
field was focused immediately, `false` when the attempt was deferred.
|
|
545
|
+
|
|
546
|
+
### onMissingComponent
|
|
547
|
+
|
|
548
|
+
If a field's `type` has no registered component, the form renders a **visible
|
|
549
|
+
placeholder** (dev and prod — never a silent drop or a wrong-widget text input)
|
|
550
|
+
and notifies this callback so you can log the gap. `hidden` fields are the one
|
|
551
|
+
exception: with no registered component they render nothing by design (the
|
|
552
|
+
value still lives in form state); register a `hidden` component to override.
|
|
553
|
+
|
|
554
|
+
```tsx
|
|
555
|
+
<FormSystemProvider
|
|
556
|
+
components={components}
|
|
557
|
+
layouts={layouts}
|
|
558
|
+
onMissingComponent={(type) => reportToTelemetry("formkit_missing_component", { type })}
|
|
559
|
+
>
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
> **Security note:** `buildValidationRules` compiles a field's `pattern` into a
|
|
563
|
+
> `new RegExp(...)`. If your schemas are ever authored by untrusted users, treat
|
|
564
|
+
> `pattern` as a ReDoS surface — validate/limit it before persisting.
|
|
565
|
+
|
|
450
566
|
## Server Components
|
|
451
567
|
|
|
452
568
|
The `@classytic/formkit/server` entry point exports server-safe utilities with no React hooks or client-side code:
|
|
@@ -475,6 +591,24 @@ import type {
|
|
|
475
591
|
|
|
476
592
|
Use this entry point in React Server Components to define schemas, evaluate conditions, or use `cn` without pulling in client-side code.
|
|
477
593
|
|
|
594
|
+
### Passing a schema across the RSC → Client boundary
|
|
595
|
+
|
|
596
|
+
`FormGenerator` is a Client Component (it drives react-hook-form). If you build
|
|
597
|
+
a schema in a Server Component and pass it as a prop into the client, React
|
|
598
|
+
requires that prop to be **serializable** — so **function** members won't cross
|
|
599
|
+
the boundary:
|
|
600
|
+
|
|
601
|
+
- ❌ function `condition: (values) => …`, custom `render`, and `loadOptions`
|
|
602
|
+
(functions aren't serializable)
|
|
603
|
+
- ✅ **DSL** conditions (`{ watch, operator, value }`), and all the plain data
|
|
604
|
+
fields (`name`, `type`, `label`, `options`, `required`, `min`/`max`, …)
|
|
605
|
+
|
|
606
|
+
For fully static, server-defined schemas use DSL conditions. If a schema needs
|
|
607
|
+
function conditions / custom renders / async options, define it **inside the
|
|
608
|
+
Client Component** (e.g. the file that renders `FormGenerator`) rather than
|
|
609
|
+
passing it down from an RSC. This is a general Next.js constraint, not specific
|
|
610
|
+
to formkit.
|
|
611
|
+
|
|
478
612
|
## Advanced Features
|
|
479
613
|
|
|
480
614
|
### Conditional Fields (Function)
|
|
@@ -488,6 +622,12 @@ Use this entry point in React Server Components to define schemas, evaluate cond
|
|
|
488
622
|
}
|
|
489
623
|
```
|
|
490
624
|
|
|
625
|
+
> **Performance:** a function condition can't be statically analyzed, so the
|
|
626
|
+
> field subscribes to the **whole form** and re-evaluates on every change. For
|
|
627
|
+
> large forms prefer a DSL rule (below, whose watched paths are auto-scoped), or
|
|
628
|
+
> add `watchNames` to scope the subscription yourself:
|
|
629
|
+
> `{ condition: (v) => v.accountType === "business", watchNames: ["accountType"] }`.
|
|
630
|
+
|
|
491
631
|
### Conditional Fields (DSL Rules)
|
|
492
632
|
|
|
493
633
|
```ts
|
|
@@ -585,14 +725,20 @@ const components = {
|
|
|
585
725
|
name: "city",
|
|
586
726
|
type: "select",
|
|
587
727
|
watchNames: ["country"],
|
|
588
|
-
loadOptions: async (values) => {
|
|
589
|
-
|
|
728
|
+
loadOptions: async (values, { signal }) => {
|
|
729
|
+
// Forward `signal` so a superseded/unmounted load is cancelled.
|
|
730
|
+
const cities = await fetchCities(values.country, { signal });
|
|
590
731
|
return cities.map(c => ({ label: c.name, value: c.id }));
|
|
591
732
|
},
|
|
592
733
|
debounceMs: 300,
|
|
734
|
+
cacheOptions: true, // reuse the last fetch when a dependency is re-selected
|
|
593
735
|
}
|
|
594
736
|
```
|
|
595
737
|
|
|
738
|
+
> `cacheOptions` (default `false`) memoizes results per watched-value signature
|
|
739
|
+
> for the field's lifetime, so toggling `country` back and forth doesn't re-hit
|
|
740
|
+
> the API. Leave it off when the options can change server-side between loads.
|
|
741
|
+
|
|
596
742
|
### Custom Section Render
|
|
597
743
|
|
|
598
744
|
```ts
|