Skip to content
Closed
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
2 changes: 1 addition & 1 deletion CHANGELOG-v7.md
Original file line number Diff line number Diff line change
Expand Up @@ -749,7 +749,7 @@ should change the heading of the (upcoming) version to include a major version b
- Fixed `mergeDefaultsWithFormData()` copying the `defaults` into the result before checking their type, so a string or array `default` merged with object `formData` surfaced as index keys (e.g. a `'x'` default merged with `{ b: 2 }` produced `{ '0': 'x', b: 2 }`). The defaults are now only copied when they are themselves an object, fixing [#5405](https://github.com/rjsf-team/react-jsonschema-form/issues/5405)
- A relative `$id` nested in another resource is resolved against that resource's `$id`, as draft 2020-12 specifies, both when `createSchemaUtils()` makes a bundle's `$ref`s absolute and when `findSchemaDefinition()` looks a resource up by its `$id`, where it was read as written and a `$ref` into or inside it threw `Could not find a definition`. A `$ref` resolved through a definition is then resolved against the resource that definition sits in, including under a root without an absolute `$id`. The keywords beside a `$ref` keep the base of the schema they were written in, rebased on an `$id` beside the `$ref`, as the `$ref` itself is. An `$id` under a keyword that holds no subschemas, such as `components`, is data and starts no resource. A `$ref` that `createSchemaUtils()` would rewrite to a relative URI a validator resolves again to somewhere else, as under a root without an absolute `$id` inside `nested/child.json`, is left as written, so validating against the rewritten root keeps working. In a draft-07 or 2019-09 root, or one without `$schema`, a `#/...` `$ref` inside a definition with its own `$id` resolves in that definition's resource, where it threw `Could not find a definition`, a 2019-09 `$id` beside a `$ref` rebases it as a 2020-12 one does, and a `$ref` naming an embedded resource by its URI finds it in every draft, where only a 2020-12 root did. An embedded resource whose `$id` ends in `/`, or in an empty fragment (`root.json#`, as draft-07 schemas often write one), is found by a `$ref` to it too, fixing [#5467](https://github.com/rjsf-team/react-jsonschema-form/issues/5467)
- **BREAKING CHANGE:** `toErrorList()` lists the errors an `ErrorSchema` holds in an array for the items of an array field, which it skipped, and `validationDataMerge()` merges such an array as the object keyed by index that `toErrorSchema()` builds, rather than replacing the other `ErrorSchema`'s errors for that array with it or appending it to another array of item errors. `unwrapErrorHandler()` strips the `addError()` functions from an array of item handlers a `customValidate` returns, which it left in the `ErrorSchema`, and keys the items by index. A `null` or `undefined` an `ErrorSchema` holds for an item or field without errors is left out of the merge rather than replacing the other `ErrorSchema`'s errors there, and out of what `unwrapErrorHandler()` returns. `unwrapErrorHandler()` leaves what sits under `__errors` as it is, as the other walks do, where it unwrapped an object there. `validationDataMerge()` therefore returns such an array as the object keyed by index, also when there is nothing to merge, where it used to pass a caller's array through, and returns `validationData` itself only when its `errorSchema` holds no such array and no `null` or `undefined`. The `ErrorSchema` and `FormValidation` types reject an array as the node of array data, which their numeric index signature accepted, so a typed `ErrorSchema` literal that holds one, such as `{ list: [{ __errors: ['taken'] }] }`, no longer compiles and has to key the items by index. The new `errorSchemaChild()` returns a value an `ErrorSchema` walk descends into, a plain object or such an array, and `undefined` for an `__errors` array or a leaf ([#3827](https://github.com/rjsf-team/react-jsonschema-form/issues/3827))
- Fixed invalid template overrides being returned as components. `getTemplate()` and `getTemplates()` ignore an override that is not a component - a string naming no registered template (which used to fall back silently), a React element, or any other value - falling back to a valid `ui:globalOptions` template before the registry's own, the way `ui:field` does. An explicit own `null` is not a mistake: it opts the field out of any override, `ui:globalOptions` included, and the registry's template renders without a warning. `SchemaField` warns once per uiSchema entry, naming the field and saying when the value came from `ui:globalOptions`; both functions take a new optional `warnSource` parameter for that warning, which callers that only resolve omit and stay silent. A registry template that is not a component is also warned about, once per registry, since nothing valid is left to fall back to. `describeUnresolvedComponent()`, the helper that words these warnings, is now exported ([#5432](https://github.com/rjsf-team/react-jsonschema-form/issues/5432))
- Fixed invalid template overrides being returned as components. `getTemplate()` and `getTemplates()` ignore an override that is not a component - a string naming no registered template (which used to fall back silently), a React element, or any other value - falling back to a valid `ui:globalOptions` template before the registry's own, the way `ui:field` does. An explicit own `null` is not a mistake: it opts the field out of any override, `ui:globalOptions` included, and the registry's template renders without a warning. `SchemaField` warns once per uiSchema entry, naming the field and saying when the value came from `ui:globalOptions`; both functions take a new optional `warnSource` parameter for that warning, which callers that only resolve omit and stay silent. `describeUnresolvedComponent()`, the helper that words these warnings, is now exported ([#5432](https://github.com/rjsf-team/react-jsonschema-form/issues/5432))
- Fixed form data and schema keys named like `Object.prototype` members, such as `toString`, `constructor` and a JSON `__proto__`, being read as inherited values or written through the prototype setter. `mergeDefaultsWithFormData()`, `mergeSchemas()` and `retrieveSchema()` keep a `__proto__` property as an own key; `retrieveSchema()` no longer treats an inherited name as a declared `dependencies` key; `getDefaultFormState()` keeps the defaults of a `__proto__` schema property and an `additionalProperties` default named `constructor` or `valueOf`, and no longer turns an inherited `toString` into an own key when it matches `allOf`, or reads an inherited member of a parent `const`; `omitExtraData()` reads and writes own keys only on submit; and `getDefaultFormState()` counts a property declared as `false` as a declared property rather than an additional one, so an object `default` still seeds its other keys beside one, fixing [#5515](https://github.com/rjsf-team/react-jsonschema-form/issues/5515) ([#5524](https://github.com/rjsf-team/react-jsonschema-form/pull/5524))
- Added theme presentation metadata, which `@rjsf/core` and every theme read: the `PresentationDeclaration`, `WidgetPresentationDeclaration`, `ThemePresentation`, `MergedThemePresentation` and `FieldPresentation` types, the `WIDGET_FIELD_PRESENTATION` declaration, and `mergeThemePresentation()`, `getPresentationDeclaration()`, `resolveFieldPresentation()` and `getSelectorPresentation()`, which build a field's plan of who draws its label, description, help and errors, under which ids, and the ARIA attributes its control carries. A declaration's `uncheckedIsFalse` ([#5388](https://github.com/rjsf-team/react-jsonschema-form/issues/5388)) marks a checkbox that holds `false` unchecked ([#5490](https://github.com/rjsf-team/react-jsonschema-form/issues/5490))
- Added the helpers that read a field's plan, `getControlAttributes()`, `getControlDescribedBy()`, `getDescribedControlAttributes()`, `getWidgetDescription()`, `isLabelledByTemplate()` and `hasAdditionalKeyLabel()`, and the `optionDescriptionId()`, `optionHelpId()`, `optionLabelId()` and `selectedOptionDescriptionId()` id generators ([#5490](https://github.com/rjsf-team/react-jsonschema-form/issues/5490))
Expand Down
8 changes: 7 additions & 1 deletion packages/core/src/components/fields/FallbackField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -539,7 +539,13 @@ export default function FallbackField<
}

const reason = props.reason ?? translateString(TranslatableString.UnknownFieldType, [String(schema.type)]);
const { UnsupportedFieldTemplate } = getTemplates<T, S, F>(registry, getUiOptions<T, S, F>(uiSchema));
// Merged with the global options so the fallback in `getTemplate()` sees the same picture `SchemaField` does:
// without them an invalid own `ui:UnsupportedFieldTemplate` would fall back to `globalUiOptions`, changing which
// template renders compared with leaving the value out
const { UnsupportedFieldTemplate } = getTemplates<T, S, F>(
registry,
getUiOptions<T, S, F>(uiSchema, registry.globalUiOptions),
);

return (
<>
Expand Down
1 change: 0 additions & 1 deletion packages/utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
},
"./lib": "./lib/index.js",
"./lib/describeElementGivenAsComponent.js": null,
"./lib/loggedMessages.js": null,
"./lib/templateOverrideWarnings.js": null,
"./lib/*.js": "./lib/*.js"
},
Expand Down
23 changes: 12 additions & 11 deletions packages/utils/src/getTemplate.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import isComponentType from './isComponentType.ts';
import { isTemplateName, warnOnInvalidOverride, warnOnInvalidRegistryTemplate } from './templateOverrideWarnings.ts';
import { isTemplateName, warnOnInvalidOverride } from './templateOverrideWarnings.ts';
import type { FormContextType, TemplatesType, Registry, UIOptionsType, StrictRJSFSchema, RJSFSchema } from './types.ts';

/** The field a template-override warning should name, passed by the one caller that renders every field
Expand All @@ -19,14 +19,18 @@ export interface TemplateWarnSource<
ownUiOptions: UIOptionsType<T, S, F>;
}

/** Returns the registry template `name` refers to, or `undefined` when the registry holds nothing under it */
function lookupTemplate(customTemplates: Record<string, unknown>, name: string): unknown {
return Object.hasOwn(customTemplates, name) ? customTemplates[name] : undefined;
}

/** Returns the template with the given `name` from either the `uiSchema` if it is defined or from the `registry`
* otherwise. NOTE, since `ButtonTemplates` are not overridden in `uiSchema` only those in the `registry` are returned.
*
* An override that is not a component is ignored, warning when a `warnSource` is given: a string naming no registered
* template, a React element, or any other non-component value falls back to a valid `ui:globalOptions` template before
* the registry's own, as `resolveUiField()` does for `ui:field`. A registry template that is not a component is warned
* about once per registry. An explicit own `null` is not a mistake: it opts the field out of any override,
* `ui:globalOptions` included, so the registry's own template renders without a warning.
* the registry's own, as `resolveUiField()` does for `ui:field`. An explicit own `null` is not a mistake: it opts the
* field out of any override, `ui:globalOptions` included, so the registry's own template renders without a warning.
*
* @param name - The name of the template to fetch, restricted to the keys of `TemplatesType`
* @param registry - The `Registry` from which to read the template
Expand All @@ -53,18 +57,16 @@ export default function getTemplate<
const customTemplates: Record<string, unknown> = templates;
const uiOverrides: Record<string, unknown> = uiOptions;
const override = uiOverrides[name] ?? undefined;
// No override, or an explicit own `null` that opts out of any override (including `ui:globalOptions`, as a falsy own
// `ui:field` does): the registry's own template renders. The check on it is cached per registry, so this common path
// does not re-validate a static registry on every render of every field and widget.
// No override, or an explicit own `null` that opts out of any override (including `ui:globalOptions`, as an own
// `null` `ui:field` does, though here `''`, `0` and `false` still warn): the registry's own template renders
if (override === undefined) {
warnOnInvalidRegistryTemplate(name, customTemplates);
return templates[name];
}
// Allow templates to be customized per-field by using string keys from the registry; a string naming no registered
// template is not a component, so it falls through to the fallback below
let template: unknown = override;
if (typeof override === 'string') {
template = Object.hasOwn(customTemplates, override) ? customTemplates[override] : undefined;
template = lookupTemplate(customTemplates, override);
}
if (isComponentType(template)) {
// Indexing templates or uiOptions by a generic key results in TS2590: Expression produces a union type that is too
Expand All @@ -81,12 +83,11 @@ export default function getTemplate<
if (globalOverride !== undefined && globalOverride !== override) {
let globalTemplate: unknown = globalOverride;
if (typeof globalOverride === 'string') {
globalTemplate = Object.hasOwn(customTemplates, globalOverride) ? customTemplates[globalOverride] : undefined;
globalTemplate = lookupTemplate(customTemplates, globalOverride);
}
if (isComponentType(globalTemplate)) {
return globalTemplate as TemplatesType<T, S, F>[Name];
}
}
warnOnInvalidRegistryTemplate(name, customTemplates);
return templates[name];
}
45 changes: 40 additions & 5 deletions packages/utils/src/logOnce.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,34 @@
import { forgetLogged, rememberLogged } from './loggedMessages.ts';
import toDisplayString from './toDisplayString.ts';

export { LOG_ONCE_MAX_MESSAGES } from './loggedMessages.ts';

/** The console methods `logOnce()` can log through */
export type LogOnceLevel = 'warn' | 'error';

/** How many distinct messages are remembered before the oldest generation of them is dropped, so a long-running
* process (such as a server rendering many different schemas) can't grow the set without bound. Deduping holds for a
* working set of up to twice this and then stops abruptly: a render producing more distinct messages than that logs
* every one of them on every pass, because no message survives long enough to be seen a second time. That edge is why
* the number is larger than the mistakes a form could plausibly contain — the warnings are raised per field, not per
* mistake, so an array of a thousand rows whose item schema is misconfigured twice is two thousand distinct messages.
* What is bounded is the number of remembered messages, at twice this; how much memory they take follows what callers
* pass, since a remembered message holds the `message` and the string form of the `error`, both of which can carry
* arbitrary text, and a plain-object or array `error` is held as its whole JSON. Two full generations of the warnings
* raised inside this library, which run a few hundred bytes each, stay under a megabyte. The cap is a ceiling rather
* than an allocation, so a form warning about a handful of fields holds a handful of strings.
*/
export const LOG_ONCE_MAX_MESSAGES = 2000;

/** What has been logged is kept in two generations rather than one set: when `currentMessages` fills it becomes
* `previousMessages` and a fresh one takes its place, so a message is still remembered for at least
* `LOG_ONCE_MAX_MESSAGES` further distinct messages after it is first seen, and memory stays bounded at twice that.
* Both halves matter. Reaching the cap must not un-remember everything at once, or a render producing more distinct
* messages than the cap starts each pass with nothing remembered and logs all of them every time. And it must not stop
* remembering either, since keys carrying a per-row id or index, or another tenant's form under SSR, fill the cap with
* messages that never recur, and anything first seen afterwards would then log on every render for the rest of the
* process's life.
*/
let currentMessages = new Set<string>();
let previousMessages = new Set<string>();

/** Logs `message` (followed by `error`, when there is one) through `console.warn()` or `console.error()`, but only the
* first time that combination of `level`, `message` and `error` is seen, so that a warning raised while rendering isn't
* repeated on every re-render. The `error` is compared by its type and its `toDisplayString()` form, so two distinct
Expand All @@ -25,9 +48,18 @@ export default function logOnce(message: string, level: LogOnceLevel = 'warn', e
error === undefined
? `${level}\u0000${message}`
: `${level}\u0000${message}\u0000${typeof error}\u0000${toDisplayString(error)}`;
if (!rememberLogged(key)) {
// A message found in `previousMessages` is deliberately left there rather than promoted into `currentMessages`, so
// one seen on every render is logged a second time once two rotations have displaced it. Promoting it would hold it
// forever, at the cost of a slot in the current generation too, which is what caps how large a working set stays
// deduped: the largest one that survives, of exactly twice the cap, would drop to one log per pass.
if (currentMessages.has(key) || previousMessages.has(key)) {
return;
}
if (currentMessages.size >= LOG_ONCE_MAX_MESSAGES) {
previousMessages = currentMessages;
currentMessages = new Set();
}
currentMessages.add(key);
const args = error === undefined ? [message] : [message, error];
// `level` is only type-checked, so a JS caller can pass anything; `console[level]` would throw inside the very
// render that was trying to warn, and the key is already remembered by then, so the message would never log at all.
Expand All @@ -47,4 +79,7 @@ export default function logOnce(message: string, level: LogOnceLevel = 'warn', e
* playground does: a wizard whose steps reuse the same field ids, a schema editor or a form builder would otherwise
* have the second schema's warnings held back by the first's.
*/
export const resetLogOnce = forgetLogged;
export function resetLogOnce() {
currentMessages.clear();
previousMessages.clear();
}
Loading
Loading