diff --git a/packages/angular/src/library/abstract-control.ts b/packages/angular/src/library/abstract-control.ts index 08f2b806c..a44ba186b 100644 --- a/packages/angular/src/library/abstract-control.ts +++ b/packages/angular/src/library/abstract-control.ts @@ -26,10 +26,10 @@ import { Actions, computeLabel, ControlElement, + Id, JsonFormsState, JsonSchema, OwnPropsOfControl, - removeId, StatePropsOfControl, } from '@jsonforms/core'; import { Component, inject, Input, OnDestroy, OnInit } from '@angular/core'; @@ -148,7 +148,7 @@ export abstract class JsonFormsAbstractControl< ngOnDestroy() { super.ngOnDestroy(); - removeId(this.id); + Id.removeId(this.id); } isEnabled(): boolean { diff --git a/packages/angular/src/library/jsonforms.component.ts b/packages/angular/src/library/jsonforms.component.ts index 749d8bd26..e8fec800e 100644 --- a/packages/angular/src/library/jsonforms.component.ts +++ b/packages/angular/src/library/jsonforms.component.ts @@ -32,7 +32,7 @@ import { ViewContainerRef, } from '@angular/core'; import { - createId, + Id, isControl, getConfig, JsonFormsProps, @@ -137,7 +137,7 @@ export class JsonFormsOutlet const controlInstance = instance as JsonFormsControl; if (controlInstance.id === undefined) { const id = isControl(props.uischema) - ? createId(props.uischema.scope) + ? Id.createId(props.uischema.scope) : undefined; (instance as JsonFormsControl).id = id; } diff --git a/packages/core/src/util/ids.ts b/packages/core/src/util/ids.ts index 4914754f4..a5b5bbd3f 100644 --- a/packages/core/src/util/ids.ts +++ b/packages/core/src/util/ids.ts @@ -23,30 +23,91 @@ THE SOFTWARE. */ -const usedIds: Set = new Set(); +interface PrefixState { + used: Set; + next: number; +} + +const idSlots = new Map(); +const prefixStates = new Map(); const makeId = (idBase: string, iteration: number) => iteration <= 1 ? idBase : idBase + iteration.toString(); -const isUniqueId = (idBase: string, iteration: number) => { - const newID = makeId(idBase, iteration); - return !usedIds.has(newID); -}; - export const createId = (proposedId: string) => { if (proposedId === undefined) { // failsafe to avoid endless loops in error cases proposedId = 'undefined'; } - let tries = 0; - while (!isUniqueId(proposedId, tries)) { + let state = prefixStates.get(proposedId); + if (state === undefined) { + state = { used: new Set(), next: 0 }; + prefixStates.set(proposedId, state); + } + // Start from the smallest index that hasn't been allocated for this prefix. + // Holes left by removeId reset `next`, so released ids are reused. + let tries = state.next; + while (state.used.has(tries) || idSlots.has(makeId(proposedId, tries))) { tries++; } - const newID = makeId(proposedId, tries); - usedIds.add(newID); - return newID; + const newId = makeId(proposedId, tries); + state.used.add(tries); + state.next = tries + 1; + idSlots.set(newId, { prefix: proposedId, index: tries }); + return newId; +}; + +export const removeId = (id: string) => { + const slot = idSlots.get(id); + if (slot === undefined) { + return false; + } + idSlots.delete(id); + const state = prefixStates.get(slot.prefix); + if (state !== undefined) { + state.used.delete(slot.index); + if (slot.index < state.next) { + state.next = slot.index; + } + if (state.used.size === 0) { + prefixStates.delete(slot.prefix); + } + } + return true; }; -export const removeId = (id: string) => usedIds.delete(id); +export const clearAllIds = () => { + idSlots.clear(); + prefixStates.clear(); +}; -export const clearAllIds = () => usedIds.clear(); +/** + * Mutable registry of the ID generation functions used internally by JSON Forms. + * + * Adopters can override one or more of these methods to provide a custom HTML + * ID strategy (e.g. for performance reasons or to integrate with an existing + * scheme). Reassigning the methods only affects callers that go through this + * object; the standalone `createId`, `removeId` and `clearAllIds` exports + * always invoke the default implementations. + * + * @example + * ```ts + * import { Id } from '@jsonforms/core'; + * + * let next = 0; + * Id.createId = () => `jf-${next++}`; + * Id.removeId = () => true; + * Id.clearAllIds = () => { + * next = 0; + * }; + * ``` + */ +export const Id: { + createId: (proposedId: string) => string; + removeId: (id: string) => boolean; + clearAllIds: () => void; +} = { + createId, + removeId, + clearAllIds, +}; diff --git a/packages/core/test/util/ids.test.ts b/packages/core/test/util/ids.test.ts new file mode 100644 index 000000000..34539f069 --- /dev/null +++ b/packages/core/test/util/ids.test.ts @@ -0,0 +1,111 @@ +/* + The MIT License + + Copyright (c) 2017-2019 EclipseSource Munich + https://github.com/eclipsesource/jsonforms + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. +*/ +import test from 'ava'; +import { clearAllIds, createId, Id, removeId } from '../../src/util/ids'; + +test.beforeEach(() => { + clearAllIds(); +}); + +test.serial('returns the proposed id when not yet taken', (t) => { + t.is(createId('foo'), 'foo'); +}); + +test.serial( + 'appends a suffix starting at 2 on subsequent calls with the same id', + (t) => { + t.is(createId('name'), 'name'); + t.is(createId('name'), 'name2'); + t.is(createId('name'), 'name3'); + } +); + +test.serial( + 'avoids collisions across prefixes that would otherwise share an id', + (t) => { + t.is(createId('name'), 'name'); + t.is(createId('name'), 'name2'); + // proposed id 'name2' must not clash with the previously generated 'name2' + t.is(createId('name2'), 'name22'); + } +); + +test.serial('reuses ids that have been released via removeId', (t) => { + const a = createId('item'); + const b = createId('item'); + const c = createId('item'); + t.deepEqual([a, b, c], ['item', 'item2', 'item3']); + removeId(b); + t.is(createId('item'), 'item2'); +}); + +test.serial('removeId reports whether the id was tracked', (t) => { + createId('foo'); + t.true(removeId('foo')); + t.false(removeId('foo')); + t.false(removeId('unknown')); +}); + +test.serial('clearAllIds resets the generator state', (t) => { + createId('item'); + createId('item'); + clearAllIds(); + t.is(createId('item'), 'item'); +}); + +test.serial( + "falls back to 'undefined' when the proposed id is undefined", + (t) => { + t.is(createId(undefined as unknown as string), 'undefined'); + t.is(createId(undefined as unknown as string), 'undefined2'); + } +); + +test.serial( + 'Id object exposes the same functions as the standalone exports', + (t) => { + t.is(Id.createId, createId); + t.is(Id.removeId, removeId); + t.is(Id.clearAllIds, clearAllIds); + } +); + +test.serial( + 'Id object routes through whatever methods are currently set', + (t) => { + const original = Id.createId; + let calls = 0; + Id.createId = (proposed) => { + calls++; + return `custom-${proposed}`; + }; + try { + t.is(Id.createId('foo'), 'custom-foo'); + t.is(calls, 1); + } finally { + Id.createId = original; + } + } +); diff --git a/packages/material-renderers/src/layouts/ExpandPanelRenderer.tsx b/packages/material-renderers/src/layouts/ExpandPanelRenderer.tsx index 53d528dc2..7314f3565 100644 --- a/packages/material-renderers/src/layouts/ExpandPanelRenderer.tsx +++ b/packages/material-renderers/src/layouts/ExpandPanelRenderer.tsx @@ -25,8 +25,7 @@ import { update, JsonFormsCellRendererRegistryEntry, JsonFormsUISchemaRegistryEntry, - createId, - removeId, + Id, ArrayTranslations, computeChildLabel, UpdateArrayContext, @@ -90,11 +89,11 @@ export interface ExpandPanelProps DispatchPropsOfExpandPanel {} const ExpandPanelRendererComponent = (props: ExpandPanelProps) => { - const [labelHtmlId] = useState(createId('expand-panel')); + const [labelHtmlId] = useState(Id.createId('expand-panel')); useEffect(() => { return () => { - removeId(labelHtmlId); + Id.removeId(labelHtmlId); }; }, [labelHtmlId]); diff --git a/packages/react/src/JsonForms.tsx b/packages/react/src/JsonForms.tsx index 38ed5514d..3538de0af 100644 --- a/packages/react/src/JsonForms.tsx +++ b/packages/react/src/JsonForms.tsx @@ -28,8 +28,8 @@ import type Ajv from 'ajv'; import type { ErrorObject } from 'ajv'; import { UnknownRenderer } from './UnknownRenderer'; import { - createId, Generate, + Id, isControl, JsonFormsCellRendererRegistryEntry, JsonFormsCore, @@ -40,7 +40,6 @@ import { JsonSchema, Middleware, OwnPropsOfJsonFormsRenderer, - removeId, UISchemaElement, ValidationMode, } from '@jsonforms/core'; @@ -66,23 +65,23 @@ export class JsonFormsDispatchRenderer extends React.Component< super(props); this.state = { id: isControl(props.uischema) - ? createId(props.uischema.scope) + ? Id.createId(props.uischema.scope) : undefined, }; } componentWillUnmount() { if (isControl(this.props.uischema)) { - removeId(this.state.id); + Id.removeId(this.state.id); } } componentDidUpdate(prevProps: JsonFormsProps) { if (prevProps.schema !== this.props.schema) { - removeId(this.state.id); + Id.removeId(this.state.id); this.setState({ id: isControl(this.props.uischema) - ? createId(this.props.uischema.scope) + ? Id.createId(this.props.uischema.scope) : undefined, }); } diff --git a/packages/vue/src/jsonFormsCompositions.ts b/packages/vue/src/jsonFormsCompositions.ts index fb519e6f1..26fe43b5c 100644 --- a/packages/vue/src/jsonFormsCompositions.ts +++ b/packages/vue/src/jsonFormsCompositions.ts @@ -29,8 +29,7 @@ import { mapStateToDispatchCellProps, mapStateToOneOfEnumCellProps, StatePropsOfJsonFormsRenderer, - createId, - removeId, + Id, mapStateToMultiEnumControlProps, mapDispatchToMultiEnumProps, mapStateToLabelProps, @@ -203,7 +202,7 @@ export function useControl< onBeforeMount(() => { if (control.value.uischema.scope) { - id.value = createId(control.value.uischema.scope); + id.value = Id.createId(control.value.uischema.scope); } }); @@ -212,16 +211,16 @@ export function useControl< (newSchema, prevSchem) => { if (newSchema !== prevSchem && isControl(control.value.uischema)) { if (id.value) { - removeId(id.value); + Id.removeId(id.value); } - id.value = createId(control.value.uischema.scope); + id.value = Id.createId(control.value.uischema.scope); } } ); onUnmounted(() => { if (id.value) { - removeId(id.value); + Id.removeId(id.value); id.value = undefined; } });