@akinon/pz-theme 2.0.71-rc.0 → 2.0.72-beta.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.
@@ -0,0 +1,162 @@
1
+ 'use client';
2
+
3
+ import React, { useEffect, useMemo } from 'react';
4
+ import type { BlockRendererProps } from '../blocks/block-renderer-registry';
5
+ import { DesignerPlaceholder } from '../blocks/product-block-utils';
6
+ import { devWarn } from '../utils/dev-warn';
7
+ import { CustomBlockContext } from './block-context';
8
+ import type { CustomBlockContextValue } from './block-context';
9
+ import { finishCollection, getCollectSink, isCollecting } from './collect';
10
+ import type { CustomBlockDefinition } from './define';
11
+
12
+ interface CustomBlockErrorBoundaryProps {
13
+ type: string;
14
+ label: string;
15
+ isDesigner: boolean;
16
+ children: React.ReactNode;
17
+ }
18
+
19
+ /**
20
+ * Per-BLOCK isolation, deliberately not the per-section one.
21
+ *
22
+ * `SectionErrorBoundary` already catches this throw, and its recovery is to
23
+ * render the whole section as null — a brand block with a typo would blank the
24
+ * header, the hero and everything else composed beside it. Catching one level
25
+ * lower keeps the blast radius at the block a developer actually wrote, and
26
+ * everything around it renders normally.
27
+ *
28
+ * Live renders nothing (an end user never sees a broken block). The designer
29
+ * gets a labelled box instead, because a block that silently disappears from
30
+ * the canvas reads as "the editor lost my block" rather than "this component
31
+ * threw" — and the merchant still needs something to select and delete.
32
+ */
33
+ class CustomBlockErrorBoundary extends React.Component<
34
+ CustomBlockErrorBoundaryProps,
35
+ { hasError: boolean }
36
+ > {
37
+ state = { hasError: false };
38
+
39
+ static getDerivedStateFromError(): { hasError: boolean } {
40
+ return { hasError: true };
41
+ }
42
+
43
+ componentDidCatch(error: unknown): void {
44
+ devWarn(
45
+ 'customBlock',
46
+ `"${this.props.type}" threw while rendering and was skipped`,
47
+ error
48
+ );
49
+ }
50
+
51
+ render(): React.ReactNode {
52
+ if (!this.state.hasError) return this.props.children;
53
+
54
+ return this.props.isDesigner ? (
55
+ <DesignerPlaceholder
56
+ label={`${this.props.label} failed to render — see the browser console`}
57
+ />
58
+ ) : null;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Adapts a developer component to `BlockRendererProps`, which is the only
64
+ * shape `blockRendererRegistry` can hold.
65
+ *
66
+ * The host is what makes the authoring API ordinary React: it converts a
67
+ * renderer's positional props into the context the public helpers read, so a
68
+ * `<ThemeEditable>` nested arbitrarily deep resolves without the developer
69
+ * threading anything through their own tree.
70
+ *
71
+ * A COMPONENT THAT TAKES ITS IDENTITY AS PROPS, never a factory `register.ts`
72
+ * calls. This file carries `'use client'`, and it has to — `React.Component`
73
+ * and `createContext` are both `undefined` under the `react-server` condition,
74
+ * so the error boundary below would die at module evaluation in the RSC graph.
75
+ * That directive also makes every export here a client REFERENCE for anyone
76
+ * holding the server-side copy, and React makes such a reference THROW when it
77
+ * is called. Taking identity as props is what lets `bindHost` in `register.ts`
78
+ * only ever *reference* this component, inside `createElement`, which resolves
79
+ * from either graph — a factory would have to be called.
80
+ */
81
+ export interface CustomBlockHostProps extends BlockRendererProps {
82
+ /** The `custom:<vendor>:<name>` this instance renders. */
83
+ type: string;
84
+ /** Picker label, reused by the error box below. */
85
+ label: string;
86
+ definition: CustomBlockDefinition;
87
+ }
88
+
89
+ export const CustomBlockHost = ({
90
+ type,
91
+ label,
92
+ definition,
93
+ block,
94
+ placeholderId,
95
+ sectionId,
96
+ isDesigner,
97
+ selectedBlockId,
98
+ currentBreakpoint = 'desktop'
99
+ }: CustomBlockHostProps) => {
100
+ const BrandBlock = definition.component;
101
+
102
+ // Read during render, not in the effect: the sink has to be in the context
103
+ // BEFORE the developer component runs, because that is when
104
+ // `useThemeSetting`/`ThemeEditable` report themselves.
105
+ const collecting = isCollecting(type);
106
+
107
+ // Closing on commit rather than on the render call is what makes the
108
+ // StrictMode double render harmless — see `finishCollection`.
109
+ useEffect(() => {
110
+ finishCollection(type);
111
+ }, [type]);
112
+
113
+ // Read off the DEFINITION rather than the catalog descriptor: the descriptor's
114
+ // slot list grows as runtime collection discovers slots, and `ThemeSlot`
115
+ // resolves a bare `<ThemeSlot />` against this list. A list that grew mid
116
+ // render would resolve the bare slot onto whichever name happened to render
117
+ // first.
118
+ const declaredSlotNames = useMemo(
119
+ () => (definition.options.slots ?? []).map((slot) => slot.name ?? ''),
120
+ [definition]
121
+ );
122
+
123
+ const value = useMemo<CustomBlockContextValue>(
124
+ () => ({
125
+ block,
126
+ placeholderId,
127
+ sectionId,
128
+ isDesigner,
129
+ currentBreakpoint,
130
+ selectedBlockId,
131
+ declaredSlotNames,
132
+ collect: collecting ? getCollectSink(type) : undefined
133
+ }),
134
+ [
135
+ type,
136
+ block,
137
+ placeholderId,
138
+ sectionId,
139
+ isDesigner,
140
+ currentBreakpoint,
141
+ selectedBlockId,
142
+ declaredSlotNames,
143
+ collecting
144
+ ]
145
+ );
146
+
147
+ return (
148
+ <CustomBlockContext.Provider value={value}>
149
+ <CustomBlockErrorBoundary
150
+ type={type}
151
+ label={label}
152
+ isDesigner={isDesigner}
153
+ >
154
+ <BrandBlock />
155
+ </CustomBlockErrorBoundary>
156
+ </CustomBlockContext.Provider>
157
+ );
158
+ };
159
+
160
+ // Named for the React devtools tree: without this every custom block on the
161
+ // page appears as the same anonymous arrow function.
162
+ CustomBlockHost.displayName = 'CustomBlockHost';
@@ -0,0 +1,97 @@
1
+ import { devWarn } from '../utils/dev-warn';
2
+ import { CUSTOM_LIMITS } from './types';
3
+ import type { CustomBlockDescriptor, CustomCatalog } from './types';
4
+
5
+ /**
6
+ * The serializable half of every registered custom block, for the editor.
7
+ *
8
+ * Module state, one Map per bundle. That is the same shape `placeholderRegistry`
9
+ * uses and it inherits the same hazard: the client and server module graphs each
10
+ * get their own copy, and only the graph the brand barrel was imported into ever
11
+ * fills one. That is the CLIENT graph and only the client graph —
12
+ * `registerCustomBlocks` is client-only, so a brand imports its barrel from the
13
+ * client root and never from the server layout. Nothing is lost by that: the
14
+ * renderer registry `ThemeBlock` reads lives in the client graph too, and the
15
+ * announce in `placeholder-registry.ts` runs from the client.
16
+ *
17
+ * This file therefore stays free of a directive of its own:
18
+ * `placeholder-registry.ts` calls `getCustomCatalog` without one, and a
19
+ * `'use client'` here would turn that call into a client reference that throws
20
+ * on the server side.
21
+ */
22
+ const catalog = new Map<string, CustomBlockDescriptor>();
23
+
24
+ let vendor = '';
25
+
26
+ export const setCatalogVendor = (next: string) => {
27
+ vendor = next;
28
+ };
29
+
30
+ export const getCatalogVendor = () => vendor;
31
+
32
+ /**
33
+ * Adds a descriptor, refusing rather than overwriting.
34
+ *
35
+ * Last-wins would be silent: `blockRendererRegistry.register` is a bare
36
+ * `Map.set`, so two blocks claiming one type would leave the second rendering
37
+ * everywhere the first was placed, with no signal anywhere.
38
+ */
39
+ export const addToCatalog = (descriptor: CustomBlockDescriptor) => {
40
+ if (catalog.has(descriptor.type)) {
41
+ throw new Error(
42
+ `[pz-theme] Custom block "${descriptor.type}" is defined twice in this build. ` +
43
+ `Type names must be unique — rename one of them.`
44
+ );
45
+ }
46
+
47
+ if (catalog.size >= CUSTOM_LIMITS.typesPerBuild) {
48
+ throw new Error(
49
+ `[pz-theme] This build declares more than ${CUSTOM_LIMITS.typesPerBuild} custom blocks. ` +
50
+ `The cap exists because the whole catalog is posted to the editor on every ` +
51
+ `placeholder announce; split the build or retire unused blocks.`
52
+ );
53
+ }
54
+
55
+ catalog.set(descriptor.type, descriptor);
56
+ };
57
+
58
+ export const getCustomBlockDescriptor = (
59
+ type: string
60
+ ): CustomBlockDescriptor | undefined => catalog.get(type);
61
+
62
+ export const getCustomBlockTypes = (): string[] => Array.from(catalog.keys());
63
+
64
+ /**
65
+ * The payload the storefront announces. Returns null when nothing is
66
+ * registered, so the announcer can omit the field entirely rather than posting
67
+ * an empty catalog every time a placeholder mounts.
68
+ */
69
+ export const getCustomCatalog = (): CustomCatalog | null => {
70
+ if (catalog.size === 0) return null;
71
+
72
+ const payload: CustomCatalog = {
73
+ vendor,
74
+ blocks: Array.from(catalog.values())
75
+ };
76
+
77
+ // Measured here rather than trusted: a descriptor grows with every setting,
78
+ // slot and option list, and the editor drops the WHOLE frame's catalog when
79
+ // it arrives over-cap. Failing loudly at the source names the build that did
80
+ // it; failing at the receiver names nothing.
81
+ const bytes = JSON.stringify(payload).length;
82
+ if (bytes > CUSTOM_LIMITS.catalogBytes) {
83
+ devWarn(
84
+ 'customCatalog',
85
+ `Catalog is ${bytes} bytes, over the ${CUSTOM_LIMITS.catalogBytes} byte cap. ` +
86
+ `The editor will reject it; reduce option lists or the number of blocks.`
87
+ );
88
+ }
89
+
90
+ return payload;
91
+ };
92
+
93
+ /** Test-only. Module state outlives a test file otherwise. */
94
+ export const resetCustomCatalog = () => {
95
+ catalog.clear();
96
+ vendor = '';
97
+ };
@@ -0,0 +1,342 @@
1
+ import { devWarn } from '../utils/dev-warn';
2
+ import { getCustomBlockDescriptor } from './catalog';
3
+ import { fieldPartName } from './editable-tags';
4
+ import { slotNameError } from './slot-nodes';
5
+ import {
6
+ SUPPORTED_PROPERTY_TYPES,
7
+ isSupportedPropertyType
8
+ } from './property-types';
9
+ import {
10
+ CUSTOM_LIMITS,
11
+ RESERVED_SETTING_KEYS,
12
+ SETTING_KEY_PATTERN,
13
+ materializeDefault
14
+ } from './types';
15
+ import type { CustomSettingDescriptor, CustomSlotDescriptor } from './types';
16
+
17
+ /**
18
+ * Runtime collection — the fallback that makes the authoring API work before
19
+ * the build-time extractor exists, and the cross-check that catches a stale
20
+ * generated descriptor after it does.
21
+ *
22
+ * A developer who declares nothing still gets a usable block: the first render
23
+ * of each type reports the `useThemeSetting` keys and the `ThemeSlot` it
24
+ * actually used, and those land in the catalog the editor receives. What
25
+ * runtime collection can never do is describe a block the merchant has not
26
+ * placed yet — nothing renders, so nothing is collected. That is the whole
27
+ * reason `defineCustomBlock` takes declarations too.
28
+ *
29
+ * The validators live in THIS file rather than beside the throwing versions in
30
+ * `register.ts` because both paths must apply the identical gate, and this is
31
+ * the only one of the four files with no inbound edge from the others
32
+ * (`register` -> `block-host` -> `collect`). Putting them in `register.ts`
33
+ * would close that chain into a cycle.
34
+ */
35
+
36
+ /**
37
+ * The short form a developer actually writes: `options: ['light','dark']`.
38
+ *
39
+ * The object member's `label` is optional because `CustomSettingDeclaration`
40
+ * (define.tsx) accepts a label-less option too, and both doors normalize
41
+ * through the one function below.
42
+ */
43
+ export type ThemeSettingChoice =
44
+ | string
45
+ | number
46
+ | { label?: string; value: unknown };
47
+
48
+ /**
49
+ * Normalizes the shorthand into the `{label, value}` pairs the editor's select
50
+ * control expects.
51
+ *
52
+ * THE ONE COPY OF THIS RULE. `registerCustomBlocks` takes the same option list
53
+ * from `defineCustomBlock` and calls this rather than carrying its own; a
54
+ * second copy drifted immediately — the declared path filled a missing `label`
55
+ * from the value while the hook path left it unset, so `[{ value: 'light' }]`
56
+ * drew a captionless control through one door and a captioned one through the
57
+ * other.
58
+ *
59
+ * It lives HERE and not beside `useThemeSetting`, which is where it reads more
60
+ * naturally, for the boundary reason `buildSetting` in `register.ts` records
61
+ * beside its `options` field: `use-theme-setting.ts` carries `'use client'` and
62
+ * this file deliberately carries no directive. Keeping the shared normalizer in
63
+ * the directive-free tier of this folder — the tier `catalog.ts` sits in, the
64
+ * one `placeholder-registry.ts` reaches without a directive of its own — leaves
65
+ * it callable from either module graph. Moving it into the hook's module would
66
+ * drag a client boundary along with it, for nothing.
67
+ */
68
+ export const normalizeSettingChoices = (
69
+ choices: ReadonlyArray<ThemeSettingChoice> | undefined
70
+ ): Array<{ label: string; value: unknown }> | undefined => {
71
+ if (!choices) return undefined;
72
+
73
+ return choices.map((choice) =>
74
+ typeof choice === 'object' && choice !== null
75
+ ? { label: String(choice.label ?? choice.value), value: choice.value }
76
+ : { label: String(choice), value: choice }
77
+ );
78
+ };
79
+
80
+ /**
81
+ * Why a control type is refused, named per type. "Unsupported" tells a
82
+ * developer nothing actionable; each of these three fails for its own reason
83
+ * and each has a different way out.
84
+ */
85
+ const UNSUPPORTED_TYPE_REASON: Record<string, string> = {
86
+ multiselect:
87
+ 'the editor stores a multiselect value as a bare array, and getResponsiveValue rejects arrays ' +
88
+ '(dev-warns and returns the fallback), so the block would read its authored default forever. ' +
89
+ 'Use "select" and store one value until a per-type resolver exists',
90
+ localetext:
91
+ 'the editor stores a localetext value as a locale map with no "desktop" key, so getResponsiveValue ' +
92
+ 'falls back and the block would read its authored default forever. Use "text" and localize in the component',
93
+ richtext:
94
+ 'there is no safe renderer for it here — the sanitizer this repo owns parses by assigning to innerHTML ' +
95
+ 'on a live element, and pz-theme renders on the server where there is no document at all. Use "text"'
96
+ };
97
+
98
+ /** The reason a setting key is refused, or null. Shared by the declared and the collected path. */
99
+ export const settingKeyError = (key: unknown): string | null => {
100
+ if (typeof key !== 'string' || !SETTING_KEY_PATTERN.test(key)) {
101
+ return (
102
+ `key ${JSON.stringify(
103
+ key
104
+ )} is not a valid setting key. Use a camelCase name starting with a ` +
105
+ `lowercase letter (max 32 characters) — the key becomes both an object key in block.properties ` +
106
+ `and the "${fieldPartName(
107
+ '<key>'
108
+ )}" part name, so both grammars have to accept it`
109
+ );
110
+ }
111
+
112
+ if (RESERVED_SETTING_KEYS.includes(key)) {
113
+ return (
114
+ `key "${key}" is reserved. The editor's property panel already treats it specially, so the field ` +
115
+ `would inherit behaviour it never asked for (a bespoke editor, a url() wrapper, or removal from ` +
116
+ `the Content tab). Rename it`
117
+ );
118
+ }
119
+
120
+ return null;
121
+ };
122
+
123
+ /** The reason a control type is refused, or null. */
124
+ export const settingTypeError = (type: unknown): string | null => {
125
+ if (isSupportedPropertyType(type)) return null;
126
+
127
+ const named = typeof type === 'string' ? type : String(type);
128
+ const reason = UNSUPPORTED_TYPE_REASON[named];
129
+
130
+ if (reason)
131
+ return `control type "${named}" cannot deliver a value: ${reason}`;
132
+
133
+ return (
134
+ `control type "${named}" is not one of the editor's control types. ` +
135
+ `Supported: ${SUPPORTED_PROPERTY_TYPES.join(', ')}`
136
+ );
137
+ };
138
+
139
+ export interface CustomCollectSink {
140
+ setting: (descriptor: CustomSettingDescriptor) => void;
141
+ slot: (descriptor: CustomSlotDescriptor) => void;
142
+ }
143
+
144
+ const sinks = new Map<string, CustomCollectSink>();
145
+ const finished = new Set<string>();
146
+
147
+ export const isCollecting = (type: string): boolean => !finished.has(type);
148
+
149
+ /**
150
+ * Closed after the first COMMITTED render of a type, never on the first render
151
+ * call: React may render a component twice before committing (StrictMode, a
152
+ * discarded concurrent attempt), and closing on the first call would drop
153
+ * everything the surviving render reported.
154
+ */
155
+ export const finishCollection = (type: string): void => {
156
+ finished.add(type);
157
+ };
158
+
159
+ const disagreement = (
160
+ declared: CustomSettingDescriptor,
161
+ collected: CustomSettingDescriptor
162
+ ): string | null => {
163
+ if (declared.type !== collected.type) {
164
+ return `type "${declared.type}" vs "${collected.type}"`;
165
+ }
166
+
167
+ const collectedDefault = materializeDefault(collected.defaultValue);
168
+ if (
169
+ JSON.stringify(declared.defaultValue) !== JSON.stringify(collectedDefault)
170
+ ) {
171
+ return `default ${JSON.stringify(
172
+ declared.defaultValue
173
+ )} vs ${JSON.stringify(collectedDefault)}`;
174
+ }
175
+
176
+ return null;
177
+ };
178
+
179
+ /**
180
+ * One stable sink per type.
181
+ *
182
+ * Stable identity matters because the sink sits inside the context value the
183
+ * host memoizes: a fresh object per render would invalidate that memo on every
184
+ * render of every custom block on the page.
185
+ */
186
+ export const getCollectSink = (type: string): CustomCollectSink => {
187
+ const existing = sinks.get(type);
188
+ if (existing) return existing;
189
+
190
+ const targetDescriptor = () => {
191
+ const descriptor = getCustomBlockDescriptor(type);
192
+ if (!descriptor) {
193
+ devWarn(
194
+ 'customBlock',
195
+ `"${type}" rendered with no catalog descriptor, so nothing it uses can be collected. ` +
196
+ `The block registered into a different module copy than the one rendering it: check that the ` +
197
+ `barrel calling registerCustomBlocks() is imported from the CLIENT ROOT, and clear .next — ` +
198
+ `webpack keeps serving the old copy of a changed package.`
199
+ );
200
+ }
201
+ return descriptor;
202
+ };
203
+
204
+ const sink: CustomCollectSink = {
205
+ setting: (collected) => {
206
+ if (finished.has(type)) return;
207
+
208
+ const descriptor = targetDescriptor();
209
+ if (!descriptor) return;
210
+
211
+ const keyError = settingKeyError(collected.key);
212
+ const typeError = keyError ? null : settingTypeError(collected.type);
213
+ if (keyError || typeError) {
214
+ devWarn(
215
+ 'customBlock',
216
+ `"${type}" used a setting the editor cannot offer: ${
217
+ keyError ?? typeError
218
+ }`
219
+ );
220
+ return;
221
+ }
222
+
223
+ const declared = descriptor.settings.find(
224
+ (setting) => setting.key === collected.key
225
+ );
226
+
227
+ if (declared) {
228
+ // The declaration always wins: it is what the editor already has and
229
+ // what the build-time extractor will regenerate. A mismatch means the
230
+ // declaration no longer describes the component — the stale-descriptor
231
+ // signal this cross-check exists to raise.
232
+ const conflict = disagreement(declared, collected);
233
+ if (conflict) {
234
+ devWarn(
235
+ 'customBlock',
236
+ `"${type}" setting "${collected.key}" disagrees with its declaration (${conflict}). ` +
237
+ `The declaration wins; update defineCustomBlock() so the picker matches the component.`
238
+ );
239
+ }
240
+ return;
241
+ }
242
+
243
+ if (descriptor.settings.length >= CUSTOM_LIMITS.settingsPerType) {
244
+ devWarn(
245
+ 'customBlock',
246
+ `"${type}" uses more than ${CUSTOM_LIMITS.settingsPerType} settings; "${collected.key}" was ` +
247
+ `dropped. Split the block — the whole catalog is posted to the editor on every placeholder announce.`
248
+ );
249
+ return;
250
+ }
251
+
252
+ descriptor.settings.push({
253
+ ...collected,
254
+ label: collected.label || collected.key,
255
+ defaultValue: materializeDefault(collected.defaultValue)
256
+ });
257
+
258
+ // The part name comes from the render path's own helper rather than a
259
+ // second copy of the same interpolation: `ThemeEditable` stamps the
260
+ // element with `partAttrs(fieldPartName(name))`, and a part the catalog
261
+ // advertises but the DOM never carries is a control the editor offers
262
+ // and nothing answers.
263
+ const part = fieldPartName(collected.key);
264
+
265
+ if (!collected.editable || descriptor.parts.includes(part)) return;
266
+
267
+ // The one place a descriptor can grow past the parts cap AFTER
268
+ // registration checked it. `register.ts` counts the declared parts and
269
+ // the declared fields together, but an undeclared `ThemeEditable`
270
+ // discovered at render time adds one more — and the cap is the editor's,
271
+ // which drops the WHOLE catalog above it. Losing this field's part group
272
+ // costs one control; letting it through would cost the brand every block
273
+ // in the palette.
274
+ if (descriptor.parts.length >= CUSTOM_LIMITS.partsPerType) {
275
+ devWarn(
276
+ 'customBlock',
277
+ `"${type}" advertises more than ${CUSTOM_LIMITS.partsPerType} parts; the "${part}" part was ` +
278
+ `dropped, so "${collected.key}" has a control in the Content tab but no styling group. ` +
279
+ `Declare fewer parts in defineCustomBlock() — the editor refuses a catalog over the cap outright.`
280
+ );
281
+ return;
282
+ }
283
+
284
+ descriptor.parts.push(part);
285
+ },
286
+
287
+ slot: (collected) => {
288
+ if (finished.has(type)) return;
289
+
290
+ const descriptor = targetDescriptor();
291
+ if (!descriptor) return;
292
+
293
+ const name = collected.name ?? '';
294
+
295
+ const nameError = slotNameError(name);
296
+ if (nameError) {
297
+ devWarn(
298
+ 'customBlock',
299
+ `"${type}" rendered a slot the editor cannot offer: ${nameError}`
300
+ );
301
+ return;
302
+ }
303
+
304
+ // Idempotent by name, and it has to be: this runs on every render until
305
+ // the first commit, so a repeat under one name is a re-render, never a
306
+ // second slot. Counting instead would cry wolf on the StrictMode double
307
+ // render — and it would be counting the wrong thing anyway, because a
308
+ // genuine duplicate never reaches this sink: `theme-slot.tsx` claims each
309
+ // NAME once per context value and the losers return without reporting.
310
+ // register.ts refuses the declared form of the same mistake, where the
311
+ // count is unambiguous.
312
+ if (descriptor.slots.some((slot) => slot.name === name)) {
313
+ descriptor.hasChildren = true;
314
+ return;
315
+ }
316
+
317
+ if (descriptor.slots.length >= CUSTOM_LIMITS.slotsPerType) {
318
+ devWarn(
319
+ 'customBlock',
320
+ `"${type}" opens more than ${CUSTOM_LIMITS.slotsPerType} slots; "${name}" was dropped. ` +
321
+ `Split the block — the whole catalog is posted to the editor on every placeholder announce.`
322
+ );
323
+ return;
324
+ }
325
+
326
+ descriptor.slots.push({
327
+ name,
328
+ ...(collected.label !== undefined ? { label: collected.label } : {})
329
+ });
330
+ descriptor.hasChildren = true;
331
+ }
332
+ };
333
+
334
+ sinks.set(type, sink);
335
+ return sink;
336
+ };
337
+
338
+ /** Test-only. Module state outlives a test file otherwise. */
339
+ export const resetCustomCollection = (): void => {
340
+ sinks.clear();
341
+ finished.clear();
342
+ };