@byline/admin 3.12.1 → 3.12.2
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/dist/forms/form-context.js +30 -60
- package/dist/forms/form-modals.d.ts +30 -0
- package/dist/forms/form-modals.js +189 -0
- package/dist/forms/form-renderer.js +22 -363
- package/dist/forms/form-status-display.d.ts +21 -0
- package/dist/forms/form-status-display.js +94 -0
- package/dist/forms/status-transitions.d.ts +23 -0
- package/dist/forms/status-transitions.js +37 -0
- package/dist/forms/status-transitions.test.node.d.ts +8 -0
- package/dist/forms/use-form-layout.d.ts +35 -0
- package/dist/forms/use-form-layout.js +77 -0
- package/dist/forms/use-form-layout.test.node.d.ts +8 -0
- package/dist/forms/use-tracked-slot.d.ts +45 -0
- package/dist/forms/use-tracked-slot.js +46 -0
- package/package.json +5 -5
- package/src/forms/form-context.tsx +36 -92
- package/src/forms/form-modals.tsx +186 -0
- package/src/forms/form-renderer.tsx +24 -387
- package/src/forms/form-status-display.tsx +108 -0
- package/src/forms/status-transitions.test.node.ts +87 -0
- package/src/forms/status-transitions.ts +86 -0
- package/src/forms/use-form-layout.test.node.ts +82 -0
- package/src/forms/use-form-layout.ts +134 -0
- package/src/forms/use-tracked-slot.ts +101 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { WorkflowStatus } from '@byline/core'
|
|
10
|
+
|
|
11
|
+
export interface StatusTransitions {
|
|
12
|
+
primaryStatus: WorkflowStatus | undefined
|
|
13
|
+
secondaryStatuses: WorkflowStatus[]
|
|
14
|
+
isTerminal: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Compute the primary and secondary status transitions for the ComboButton.
|
|
19
|
+
* - Primary: the main action (forward step), or the current status itself
|
|
20
|
+
* when the document has reached the final workflow step (terminal state).
|
|
21
|
+
* - Secondary: other available transitions to show as dropdown options.
|
|
22
|
+
* - isTerminal: true when the document is at the final workflow status —
|
|
23
|
+
* the primary button renders as a non-actionable indicator and all
|
|
24
|
+
* back-steps move into the dropdown.
|
|
25
|
+
*/
|
|
26
|
+
export function computeStatusTransitions(
|
|
27
|
+
currentStatus: string | undefined,
|
|
28
|
+
workflowStatuses: WorkflowStatus[] | undefined,
|
|
29
|
+
nextStatus: WorkflowStatus | undefined
|
|
30
|
+
): StatusTransitions {
|
|
31
|
+
if (!workflowStatuses || workflowStatuses.length === 0 || !currentStatus) {
|
|
32
|
+
return { primaryStatus: nextStatus, secondaryStatuses: [], isTerminal: false }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Single-status workflows (e.g. SINGLE_STATUS_WORKFLOW for lookups) have
|
|
36
|
+
// no transitions — short-circuit so the form shows only Close / Save.
|
|
37
|
+
if (workflowStatuses.length <= 1) {
|
|
38
|
+
return { primaryStatus: undefined, secondaryStatuses: [], isTerminal: false }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const currentIndex = workflowStatuses.findIndex((s) => s.name === currentStatus)
|
|
42
|
+
if (currentIndex === -1) {
|
|
43
|
+
return { primaryStatus: nextStatus, secondaryStatuses: [], isTerminal: false }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const isAtEnd = currentIndex === workflowStatuses.length - 1
|
|
47
|
+
const isAtStart = currentIndex === 0
|
|
48
|
+
|
|
49
|
+
// Collect all available target statuses
|
|
50
|
+
const availableTargets: WorkflowStatus[] = []
|
|
51
|
+
|
|
52
|
+
// Reset to first (if not at first)
|
|
53
|
+
if (!isAtStart && workflowStatuses[0]) {
|
|
54
|
+
availableTargets.push(workflowStatuses[0])
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Back one step (if not at start and the previous is not already the first)
|
|
58
|
+
const prev = workflowStatuses[currentIndex - 1]
|
|
59
|
+
if (currentIndex > 1 && prev) {
|
|
60
|
+
availableTargets.push(prev)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Forward one step (if not at end) - this is the nextStatus
|
|
64
|
+
const next = workflowStatuses[currentIndex + 1]
|
|
65
|
+
if (!isAtEnd && next) {
|
|
66
|
+
availableTargets.push(next)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (isAtEnd) {
|
|
70
|
+
// Terminal state: the primary button is a non-actionable indicator of the
|
|
71
|
+
// current status; both back-steps (revert to previous / reset to first)
|
|
72
|
+
// are surfaced in the dropdown.
|
|
73
|
+
return {
|
|
74
|
+
primaryStatus: workflowStatuses[currentIndex],
|
|
75
|
+
secondaryStatuses: availableTargets,
|
|
76
|
+
isTerminal: true,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Not at end: primary is the forward step (nextStatus)
|
|
81
|
+
return {
|
|
82
|
+
primaryStatus: nextStatus,
|
|
83
|
+
secondaryStatuses: availableTargets.filter((s) => s.name !== nextStatus?.name),
|
|
84
|
+
isTerminal: false,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { CollectionAdminConfig, Field, GroupDefinition, RowDefinition } from '@byline/core'
|
|
10
|
+
import { describe, expect, it } from 'vitest'
|
|
11
|
+
|
|
12
|
+
import { buildFieldToTabPath } from './use-form-layout'
|
|
13
|
+
|
|
14
|
+
const field = (name: string): Field => ({ name, type: 'text', label: name }) as Field
|
|
15
|
+
|
|
16
|
+
const fieldNames = ['title', 'body', 'seoTitle', 'seoDesc', 'note']
|
|
17
|
+
const fieldByName = new Map<string, Field>(fieldNames.map((n) => [n, field(n)]))
|
|
18
|
+
|
|
19
|
+
const rowByName = new Map<string, RowDefinition>([
|
|
20
|
+
['seoRow', { name: 'seoRow', fields: ['seoTitle', 'seoDesc'] }],
|
|
21
|
+
])
|
|
22
|
+
const groupByName = new Map<string, GroupDefinition>([
|
|
23
|
+
['seoGroup', { name: 'seoGroup', label: 'SEO', fields: ['seoRow'] }],
|
|
24
|
+
])
|
|
25
|
+
|
|
26
|
+
describe('buildFieldToTabPath', () => {
|
|
27
|
+
it('returns an empty map when there are no tab sets', () => {
|
|
28
|
+
const map = buildFieldToTabPath(undefined, fieldByName, rowByName, groupByName)
|
|
29
|
+
expect(map.size).toBe(0)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('maps direct fields to their tab set + tab', () => {
|
|
33
|
+
const adminConfig = {
|
|
34
|
+
tabSets: [
|
|
35
|
+
{
|
|
36
|
+
name: 'main',
|
|
37
|
+
tabs: [
|
|
38
|
+
{ name: 'content', label: 'Content', fields: ['title', 'body'] },
|
|
39
|
+
{ name: 'meta', label: 'Meta', fields: ['note'] },
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
} as unknown as CollectionAdminConfig
|
|
44
|
+
|
|
45
|
+
const map = buildFieldToTabPath(adminConfig, fieldByName, rowByName, groupByName)
|
|
46
|
+
expect(map.get('title')).toEqual({ tabSetName: 'main', tabName: 'content' })
|
|
47
|
+
expect(map.get('body')).toEqual({ tabSetName: 'main', tabName: 'content' })
|
|
48
|
+
expect(map.get('note')).toEqual({ tabSetName: 'main', tabName: 'meta' })
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('recurses through rows and groups to reach nested fields', () => {
|
|
52
|
+
const adminConfig = {
|
|
53
|
+
tabSets: [
|
|
54
|
+
{
|
|
55
|
+
name: 'main',
|
|
56
|
+
tabs: [{ name: 'seo', label: 'SEO', fields: ['seoGroup'] }],
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
} as unknown as CollectionAdminConfig
|
|
60
|
+
|
|
61
|
+
const map = buildFieldToTabPath(adminConfig, fieldByName, rowByName, groupByName)
|
|
62
|
+
// seoGroup → seoRow → [seoTitle, seoDesc]
|
|
63
|
+
expect(map.get('seoTitle')).toEqual({ tabSetName: 'main', tabName: 'seo' })
|
|
64
|
+
expect(map.get('seoDesc')).toEqual({ tabSetName: 'main', tabName: 'seo' })
|
|
65
|
+
// Container names themselves are not fields, so are not indexed.
|
|
66
|
+
expect(map.has('seoGroup')).toBe(false)
|
|
67
|
+
expect(map.has('seoRow')).toBe(false)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('does not loop forever on a self-referential row cycle', () => {
|
|
71
|
+
const cyclicRows = new Map<string, RowDefinition>([
|
|
72
|
+
['loop', { name: 'loop', fields: ['loop', 'title'] }],
|
|
73
|
+
])
|
|
74
|
+
const adminConfig = {
|
|
75
|
+
tabSets: [{ name: 'main', tabs: [{ name: 't', label: 'T', fields: ['loop'] }] }],
|
|
76
|
+
} as unknown as CollectionAdminConfig
|
|
77
|
+
|
|
78
|
+
const map = buildFieldToTabPath(adminConfig, fieldByName, cyclicRows, groupByName)
|
|
79
|
+
// The cycle guard stops the re-visit; the real field is still indexed.
|
|
80
|
+
expect(map.get('title')).toEqual({ tabSetName: 'main', tabName: 't' })
|
|
81
|
+
})
|
|
82
|
+
})
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
5
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
6
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
7
|
+
*
|
|
8
|
+
* Copyright (c) Infonomic Company Limited
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useMemo } from 'react'
|
|
12
|
+
|
|
13
|
+
import type {
|
|
14
|
+
CollectionAdminConfig,
|
|
15
|
+
Field,
|
|
16
|
+
GroupDefinition,
|
|
17
|
+
LayoutDefinition,
|
|
18
|
+
RowDefinition,
|
|
19
|
+
TabSetDefinition,
|
|
20
|
+
} from '@byline/core'
|
|
21
|
+
|
|
22
|
+
/** Which tab set + tab a schema field is placed under. */
|
|
23
|
+
export interface TabPath {
|
|
24
|
+
tabSetName: string
|
|
25
|
+
tabName: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Derived lookup tables that drive the form's layout walk and per-tab error
|
|
30
|
+
* badges. All are pure derivations of `adminConfig` + `fields`; the startup
|
|
31
|
+
* validator guarantees every reachable name resolves and every schema field is
|
|
32
|
+
* placed at most once, so the consuming render-time lookups stay unguarded.
|
|
33
|
+
*/
|
|
34
|
+
export interface FormLayout {
|
|
35
|
+
/** Schema field name → field definition. */
|
|
36
|
+
fieldByName: Map<string, Field>
|
|
37
|
+
tabSetByName: Map<string, TabSetDefinition>
|
|
38
|
+
rowByName: Map<string, RowDefinition>
|
|
39
|
+
groupByName: Map<string, GroupDefinition>
|
|
40
|
+
/** Region placement; synthesised to `{ main: <all fields> }` when omitted. */
|
|
41
|
+
layout: LayoutDefinition
|
|
42
|
+
/** Reverse index: schema field name → which tab set + tab it lives in. */
|
|
43
|
+
fieldToTabPath: Map<string, TabPath>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Build the reverse index from schema field name to its enclosing tab set +
|
|
48
|
+
* tab. Fields not under any tab set (e.g. raw-field placement directly in
|
|
49
|
+
* `layout.main`) are absent from the map. Rows and groups are recursed into;
|
|
50
|
+
* the `seen` set guards against a config that references a row/group cycle.
|
|
51
|
+
*/
|
|
52
|
+
export function buildFieldToTabPath(
|
|
53
|
+
adminConfig: CollectionAdminConfig | undefined,
|
|
54
|
+
fieldByName: Map<string, Field>,
|
|
55
|
+
rowByName: Map<string, RowDefinition>,
|
|
56
|
+
groupByName: Map<string, GroupDefinition>
|
|
57
|
+
): Map<string, TabPath> {
|
|
58
|
+
const map = new Map<string, TabPath>()
|
|
59
|
+
const visit = (
|
|
60
|
+
names: readonly string[],
|
|
61
|
+
tabSetName: string,
|
|
62
|
+
tabName: string,
|
|
63
|
+
seen: Set<string>
|
|
64
|
+
) => {
|
|
65
|
+
for (const name of names) {
|
|
66
|
+
if (fieldByName.has(name)) {
|
|
67
|
+
map.set(name, { tabSetName, tabName })
|
|
68
|
+
} else if (seen.has(name)) {
|
|
69
|
+
} else if (rowByName.has(name)) {
|
|
70
|
+
const row = rowByName.get(name)!
|
|
71
|
+
const next = new Set(seen).add(name)
|
|
72
|
+
visit(row.fields, tabSetName, tabName, next)
|
|
73
|
+
} else if (groupByName.has(name)) {
|
|
74
|
+
const group = groupByName.get(name)!
|
|
75
|
+
const next = new Set(seen).add(name)
|
|
76
|
+
visit(group.fields, tabSetName, tabName, next)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
for (const set of adminConfig?.tabSets ?? []) {
|
|
81
|
+
for (const tab of set.tabs) {
|
|
82
|
+
visit(tab.fields, set.name, tab.name, new Set())
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return map
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Memoise the layout primitives + lookup tables the form renderer walks.
|
|
90
|
+
* Rebuilt only when `adminConfig` / `fields` change.
|
|
91
|
+
*/
|
|
92
|
+
export function useFormLayout(
|
|
93
|
+
adminConfig: CollectionAdminConfig | undefined,
|
|
94
|
+
fields: Field[]
|
|
95
|
+
): FormLayout {
|
|
96
|
+
const fieldByName = useMemo(() => {
|
|
97
|
+
const map = new Map<string, Field>()
|
|
98
|
+
for (const field of fields) {
|
|
99
|
+
if ('name' in field) map.set(field.name, field)
|
|
100
|
+
}
|
|
101
|
+
return map
|
|
102
|
+
}, [fields])
|
|
103
|
+
|
|
104
|
+
const tabSetByName = useMemo(() => {
|
|
105
|
+
const map = new Map<string, TabSetDefinition>()
|
|
106
|
+
for (const set of adminConfig?.tabSets ?? []) map.set(set.name, set)
|
|
107
|
+
return map
|
|
108
|
+
}, [adminConfig])
|
|
109
|
+
|
|
110
|
+
const rowByName = useMemo(() => {
|
|
111
|
+
const map = new Map<string, RowDefinition>()
|
|
112
|
+
for (const row of adminConfig?.rows ?? []) map.set(row.name, row)
|
|
113
|
+
return map
|
|
114
|
+
}, [adminConfig])
|
|
115
|
+
|
|
116
|
+
const groupByName = useMemo(() => {
|
|
117
|
+
const map = new Map<string, GroupDefinition>()
|
|
118
|
+
for (const group of adminConfig?.groups ?? []) map.set(group.name, group)
|
|
119
|
+
return map
|
|
120
|
+
}, [adminConfig])
|
|
121
|
+
|
|
122
|
+
// When `layout` is omitted, synthesise main = all schema fields in order.
|
|
123
|
+
const layout = useMemo<LayoutDefinition>(() => {
|
|
124
|
+
if (adminConfig?.layout) return adminConfig.layout
|
|
125
|
+
return { main: fields.filter((f) => 'name' in f).map((f) => (f as { name: string }).name) }
|
|
126
|
+
}, [adminConfig, fields])
|
|
127
|
+
|
|
128
|
+
const fieldToTabPath = useMemo(
|
|
129
|
+
() => buildFieldToTabPath(adminConfig, fieldByName, rowByName, groupByName),
|
|
130
|
+
[adminConfig, fieldByName, rowByName, groupByName]
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
return { fieldByName, tabSetByName, rowByName, groupByName, layout, fieldToTabPath }
|
|
134
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
5
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
6
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
7
|
+
*
|
|
8
|
+
* Copyright (c) Infonomic Company Limited
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { type RefObject, useCallback, useRef } from 'react'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A dirty-tracked, ref-backed form slot with its own listener set — the shared
|
|
15
|
+
* machinery behind the document-grain system fields (`path`, advertised
|
|
16
|
+
* `availableLocales`, …). Each slot holds the current value, the value it was
|
|
17
|
+
* loaded with, and a set of subscribers; a `set` that moves the value away from
|
|
18
|
+
* its loaded baseline registers the slot's `dirtyKey` in the form's shared
|
|
19
|
+
* dirty set (and clears it again when the value returns to baseline), so the
|
|
20
|
+
* single Save button can branch on which buckets changed.
|
|
21
|
+
*
|
|
22
|
+
* Adding a new system slot is then a one-liner at the call site rather than a
|
|
23
|
+
* copy of ref + initial-ref + listener-set + get/set/subscribe.
|
|
24
|
+
*/
|
|
25
|
+
export interface TrackedSlot<T> {
|
|
26
|
+
/** Current value (live ref read — not React state). */
|
|
27
|
+
get: () => T
|
|
28
|
+
/** Write a value; toggles `dirtyKey` against the loaded baseline. */
|
|
29
|
+
set: (value: T) => void
|
|
30
|
+
/** Subscribe to value changes; returns an unsubscribe fn. */
|
|
31
|
+
subscribe: (listener: (value: T) => void) => () => void
|
|
32
|
+
/** Re-baseline: adopt the current value as the new "clean" baseline (called on save). */
|
|
33
|
+
commitInitial: () => void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface UseTrackedSlotConfig<T> {
|
|
37
|
+
/** Initial / loaded value. */
|
|
38
|
+
initial: T
|
|
39
|
+
/** Key registered in the shared dirty set while this slot diverges from baseline. */
|
|
40
|
+
dirtyKey: string
|
|
41
|
+
/** The form's shared dirty-key set. */
|
|
42
|
+
dirtyFields: RefObject<Set<string>>
|
|
43
|
+
/** Notify the form's meta listeners (drives hasChanges → Save button). */
|
|
44
|
+
notifyMeta: () => void
|
|
45
|
+
/** Equality against the baseline. Defaults to `===` (identity). */
|
|
46
|
+
isEqual?: (a: T, b: T) => boolean
|
|
47
|
+
/** Defensive copy on read-in / write. Defaults to identity (fine for immutables). */
|
|
48
|
+
clone?: (value: T) => T
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function useTrackedSlot<T>(config: UseTrackedSlotConfig<T>): TrackedSlot<T> {
|
|
52
|
+
// Snapshot the config on every render into a ref so the returned callbacks
|
|
53
|
+
// can stay referentially stable (empty deps) while still reading the latest
|
|
54
|
+
// isEqual / clone / notifyMeta — matching the stable identities the previous
|
|
55
|
+
// inline implementation relied on.
|
|
56
|
+
const cfgRef = useRef(config)
|
|
57
|
+
cfgRef.current = config
|
|
58
|
+
|
|
59
|
+
const clone = useCallback((value: T): T => {
|
|
60
|
+
const fn = cfgRef.current.clone
|
|
61
|
+
return fn ? fn(value) : value
|
|
62
|
+
}, [])
|
|
63
|
+
|
|
64
|
+
const valueRef = useRef<T>(clone(config.initial))
|
|
65
|
+
const initialRef = useRef<T>(clone(config.initial))
|
|
66
|
+
const listeners = useRef<Set<(value: T) => void>>(new Set())
|
|
67
|
+
|
|
68
|
+
const get = useCallback(() => valueRef.current, [])
|
|
69
|
+
|
|
70
|
+
const set = useCallback(
|
|
71
|
+
(value: T) => {
|
|
72
|
+
const { dirtyKey, dirtyFields, notifyMeta, isEqual } = cfgRef.current
|
|
73
|
+
const next = clone(value)
|
|
74
|
+
valueRef.current = next
|
|
75
|
+
const equal = isEqual ? isEqual(next, initialRef.current) : next === initialRef.current
|
|
76
|
+
if (equal) {
|
|
77
|
+
dirtyFields.current.delete(dirtyKey)
|
|
78
|
+
} else {
|
|
79
|
+
dirtyFields.current.add(dirtyKey)
|
|
80
|
+
}
|
|
81
|
+
listeners.current.forEach((listener) => {
|
|
82
|
+
listener(next)
|
|
83
|
+
})
|
|
84
|
+
notifyMeta()
|
|
85
|
+
},
|
|
86
|
+
[clone]
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
const subscribe = useCallback((listener: (value: T) => void) => {
|
|
90
|
+
listeners.current.add(listener)
|
|
91
|
+
return () => {
|
|
92
|
+
listeners.current.delete(listener)
|
|
93
|
+
}
|
|
94
|
+
}, [])
|
|
95
|
+
|
|
96
|
+
const commitInitial = useCallback(() => {
|
|
97
|
+
initialRef.current = clone(valueRef.current)
|
|
98
|
+
}, [clone])
|
|
99
|
+
|
|
100
|
+
return { get, set, subscribe, commitInitial }
|
|
101
|
+
}
|