snice 1.0.0 → 1.2.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/README.md CHANGED
@@ -23,7 +23,7 @@ Snice provides a clear separation of concerns through decorators:
23
23
  - **`@page`** - Sets up routing and navigation between different views
24
24
 
25
25
  ### Property & Query Decorators
26
- - **`@property`** - Declares reactive properties that can reflect to attributes
26
+ - **`@property`** - Declares properties that can reflect to attributes
27
27
  - **`@query`** - Queries a single element from shadow DOM
28
28
  - **`@queryAll`** - Queries multiple elements from shadow DOM
29
29
 
@@ -219,7 +219,7 @@ class MyClicker extends HTMLElement {
219
219
  Automatically dispatch custom events with `@dispatch`:
220
220
 
221
221
  ```typescript
222
- import { element, dispatch, on } from 'snice';
222
+ import { element, dispatch, on, query } from 'snice';
223
223
 
224
224
  @element('toggle-switch')
225
225
  class ToggleSwitch extends HTMLElement {
@@ -315,11 +315,22 @@ class AboutPage extends HTMLElement {
315
315
  }
316
316
  }
317
317
 
318
+ // Page with URL parameter
319
+ @page({ tag: 'user-page', routes: ['/users/:id'] })
320
+ class UserPage extends HTMLElement {
321
+ id = ''; // Automatically set from URL
322
+
323
+ html() {
324
+ return `<h1>User ${this.id}</h1>`;
325
+ }
326
+ }
327
+
318
328
  // Start the router
319
329
  initialize();
320
330
 
321
331
  // Navigate programmatically
322
332
  navigate('/about');
333
+ navigate('/users/123'); // Sets id="123" on UserPage
323
334
  ```
324
335
 
325
336
  ## Controllers (Data Fetching)
@@ -339,7 +350,7 @@ class UserController {
339
350
  (element as any).setUsers(users);
340
351
  }
341
352
 
342
- async detach() {
353
+ async detach(element: HTMLElement) {
343
354
  // Cleanup
344
355
  }
345
356
  }
@@ -358,7 +369,9 @@ class UserList extends HTMLElement {
358
369
 
359
370
  setUsers(users: any[]) {
360
371
  this.users = users;
361
- this.innerHTML = this.html();
372
+ if (this.shadowRoot) {
373
+ this.shadowRoot.innerHTML = this.html();
374
+ }
362
375
  }
363
376
  }
364
377
  ```
@@ -393,6 +406,11 @@ class UserCard extends HTMLElement {
393
406
  // --- controllers/user-controller.ts
394
407
  @controller('user-controller')
395
408
  class UserController {
409
+ element: HTMLElement | null = null;
410
+
411
+ async attach(element: HTMLElement) {}
412
+ async detach(element: HTMLElement) {}
413
+
396
414
  @channel('get-data')
397
415
  handleGetData(request) {
398
416
  console.log(request); // { id: 123 }
@@ -491,7 +509,6 @@ class WeatherController {
491
509
  element: HTMLElement | null = null;
492
510
 
493
511
  async attach(element: HTMLElement) {
494
- this.element = element;
495
512
 
496
513
  // Simulate fetching weather data
497
514
  await new Promise(resolve => setTimeout(resolve, 1000));
@@ -512,6 +529,10 @@ class WeatherController {
512
529
  `);
513
530
  (element as any).setFooter('Updated just now');
514
531
  }
532
+
533
+ async detach(element: HTMLElement) {
534
+ // Cleanup if needed
535
+ }
515
536
  }
516
537
  ```
517
538
 
@@ -557,9 +578,16 @@ Use the same card with different controllers:
557
578
 
558
579
  ```typescript
559
580
  interface PropertyOptions {
560
- type?: typeof String | typeof Number | typeof Boolean; // Type converter
561
- reflect?: boolean; // Reflect property to attribute
562
- attribute?: string; // Custom attribute name
581
+ type?: typeof String | typeof Number | typeof Boolean | typeof Array | typeof Object; // Type converter
582
+ reflect?: boolean; // Reflect property to attribute
583
+ attribute?: string | boolean; // Custom attribute name or false to disable
584
+ converter?: PropertyConverter; // Custom converter
585
+ hasChanged?: (value: any, oldValue: any) => boolean; // Custom change detector
586
+ }
587
+
588
+ interface PropertyConverter {
589
+ fromAttribute?(value: string | null, type?: any): any;
590
+ toAttribute?(value: any, type?: any): string | null;
563
591
  }
564
592
  ```
565
593
 
@@ -571,6 +599,15 @@ interface DispatchOptions extends EventInit {
571
599
  }
572
600
  ```
573
601
 
602
+ ## Documentation
603
+
604
+ - [Elements API](./docs/elements.md) - Complete guide to creating elements with properties, queries, and styling
605
+ - [Controllers API](./docs/controllers.md) - Data fetching, business logic, and controller patterns
606
+ - [Events API](./docs/events.md) - Event handling, dispatching, and custom events
607
+ - [Channels API](./docs/channels.md) - Bidirectional communication between elements and controllers
608
+ - [Routing API](./docs/routing.md) - Single-page application routing with transitions
609
+ - [Migration Guide](./docs/migration-guide.md) - Migrating from React, Vue, Angular, and other frameworks
610
+
574
611
  ## License
575
612
 
576
613
  MIT
package/package.json CHANGED
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "name": "snice",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "description": "A TypeScript library",
6
- "main": "dist/index.js",
7
- "module": "dist/index.js",
8
- "types": "dist/index.d.ts",
6
+ "main": "src/index.ts",
7
+ "module": "src/index.ts",
8
+ "types": "src/index.ts",
9
9
  "files": [
10
- "dist"
10
+ "src",
11
+ "!src/**/*.test.ts",
12
+ "!src/**/*.spec.ts"
11
13
  ],
12
14
  "publishConfig": {
13
15
  "access": "public"
package/src/channel.ts ADDED
@@ -0,0 +1,181 @@
1
+ import { CHANNEL_HANDLERS, CLEANUP } from './symbols';
2
+
3
+ export interface ChannelOptions extends EventInit {
4
+ /**
5
+ * Timeout for waiting for responses (in ms)
6
+ */
7
+ timeout?: number;
8
+ }
9
+
10
+ /**
11
+ * Decorator for bidirectional communication channels.
12
+ * On elements: Opens a channel using async generator
13
+ * On controllers: Responds to channel requests
14
+ *
15
+ * @param channelName The name of the channel
16
+ * @param options Optional configuration
17
+ */
18
+ export function channel(channelName: string, options?: ChannelOptions) {
19
+ return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
20
+ const originalMethod = descriptor.value;
21
+
22
+ // We'll determine at runtime whether this is element or controller
23
+ // by checking if 'this' is an HTMLElement
24
+ descriptor.value = async function (this: any, ...args: any[]) {
25
+ // Runtime check: if 'this' is an HTMLElement, it's element-side
26
+ if (this instanceof HTMLElement) {
27
+ // Element side - handle async generator
28
+ const timeout = options?.timeout ?? 100; // Default 100ms timeout
29
+
30
+ // Create the generator
31
+ const generator = originalMethod.apply(this, args);
32
+
33
+ // Get the first yield (the request payload)
34
+ const { value: payload, done } = await generator.next();
35
+
36
+ if (done) {
37
+ // Generator returned without yielding
38
+ return payload;
39
+ }
40
+
41
+ // Create data promise and expose resolve/reject
42
+ let dataResolve: (value: any) => void;
43
+ let dataReject: (reason?: any) => void;
44
+ const dataPromise = new Promise((resolve, reject) => {
45
+ dataResolve = resolve;
46
+ dataReject = reject;
47
+ });
48
+
49
+ // Create timeout promise and expose resolve/reject
50
+ let timeoutResolve: () => void;
51
+ let timeoutReject: (reason?: any) => void;
52
+ let timeoutId: NodeJS.Timeout;
53
+ const timeoutPromise = new Promise<void>((resolve, reject) => {
54
+ timeoutResolve = resolve;
55
+ timeoutReject = reject;
56
+ timeoutId = setTimeout(() => {
57
+ reject(new Error(`Channel timeout after ${timeout}ms`));
58
+ }, timeout);
59
+ });
60
+
61
+ // Dispatch event with promises
62
+ const eventName = `@channel:${channelName}`;
63
+ const event = new CustomEvent(eventName, {
64
+ bubbles: options?.bubbles !== undefined ? options.bubbles : true,
65
+ cancelable: options?.cancelable || false,
66
+ composed: true, // Allow crossing shadow DOM boundaries
67
+ detail: {
68
+ payload,
69
+ timeout: {
70
+ resolve: () => {
71
+ clearTimeout(timeoutId);
72
+ timeoutResolve();
73
+ },
74
+ reject: timeoutReject!
75
+ },
76
+ data: {
77
+ resolve: dataResolve!,
78
+ reject: dataReject!
79
+ }
80
+ }
81
+ });
82
+
83
+ this.dispatchEvent(event);
84
+
85
+ try {
86
+ // Wait for timeout to be resolved or rejected
87
+ await timeoutPromise;
88
+ // If we get here, controller responded in time
89
+ const response = await dataPromise;
90
+
91
+ // Send response back to generator and get final return value
92
+ const { value: finalValue } = await generator.next(response);
93
+ return finalValue;
94
+ } catch (error) {
95
+ // Send error to generator
96
+ try {
97
+ await generator.throw(error);
98
+ } catch (generatorError) {
99
+ throw generatorError;
100
+ }
101
+ }
102
+ } else {
103
+ // Controller side - just call the original method
104
+ // The actual channel setup happens in setupChannelHandlers
105
+ return originalMethod.apply(this, args);
106
+ }
107
+ };
108
+
109
+ // Store channel metadata on the prototype for controllers
110
+ // This will be picked up by setupChannelHandlers
111
+ if (!target[CHANNEL_HANDLERS]) {
112
+ target[CHANNEL_HANDLERS] = [];
113
+ }
114
+
115
+ target[CHANNEL_HANDLERS].push({
116
+ channelName,
117
+ methodName: propertyKey,
118
+ method: originalMethod
119
+ });
120
+
121
+ return descriptor;
122
+ };
123
+ }
124
+
125
+ // Helper to setup channel handlers for controllers
126
+ export function setupChannelHandlers(instance: any, element: HTMLElement) {
127
+ const handlers = instance.constructor.prototype[CHANNEL_HANDLERS];
128
+ if (!handlers) return;
129
+
130
+ // Store cleanup functions
131
+ // Initialize cleanup object if needed
132
+ if (!instance[CLEANUP]) {
133
+ instance[CLEANUP] = { events: [], channels: [] };
134
+ }
135
+
136
+ for (const handler of handlers) {
137
+ const boundMethod = handler.method.bind(instance);
138
+ const eventName = `@channel:${handler.channelName}`;
139
+
140
+ // Setup channel handler
141
+ const channelHandler = (event: CustomEvent) => {
142
+ // Extract promises and payload
143
+ const { data, timeout, payload } = event.detail;
144
+
145
+ // Prevent other controllers from responding
146
+ event.preventDefault();
147
+ event.stopImmediatePropagation();
148
+ event.stopPropagation();
149
+
150
+ // Call the controller method and handle the result
151
+ Promise.resolve(boundMethod(payload))
152
+ .then(result => {
153
+ // Clear the timeout and resolve the data promise
154
+ timeout.resolve();
155
+ data.resolve(result);
156
+ })
157
+ .catch(error => {
158
+ // Clear timeout and reject the data promise on error
159
+ timeout.resolve();
160
+ data.reject(error);
161
+ console.error(`Error in channel handler ${handler.methodName}:`, error);
162
+ });
163
+ };
164
+
165
+ element.addEventListener(eventName, channelHandler as EventListener);
166
+
167
+ instance[CLEANUP].channels.push(() => {
168
+ element.removeEventListener(eventName, channelHandler as EventListener);
169
+ });
170
+ }
171
+ }
172
+
173
+ // Helper to cleanup channel handlers
174
+ export function cleanupChannelHandlers(instance: any) {
175
+ if (instance[CLEANUP]?.channels) {
176
+ for (const cleanup of instance[CLEANUP].channels) {
177
+ cleanup();
178
+ }
179
+ instance[CLEANUP].channels = [];
180
+ }
181
+ }
@@ -0,0 +1,329 @@
1
+ import { setupEventHandlers, cleanupEventHandlers } from './events';
2
+ import { setupChannelHandlers, cleanupChannelHandlers } from './channel';
3
+ import { IS_CONTROLLER_CLASS, CONTROLLER_KEY, CONTROLLER_NAME_KEY, CONTROLLER_ID, CONTROLLER_OPERATIONS, NATIVE_CONTROLLER, IS_ELEMENT_CLASS } from './symbols';
4
+ import { snice } from './global';
5
+
6
+ type Maybe<T> = T | null | undefined;
7
+
8
+ export interface IController<T extends HTMLElement = HTMLElement> {
9
+ element: Maybe<T>;
10
+ attach(element: T): void | Promise<void>;
11
+ detach(element: T): void | Promise<void>;
12
+ }
13
+
14
+ export type ControllerClass<T extends HTMLElement = HTMLElement> = new() => IController<T>;
15
+
16
+ // Controller-scoped cleanup registry
17
+ class ControllerScope {
18
+ private cleanupFns: Map<string, Function> = new Map();
19
+ private pendingOperations: Set<Promise<void>> = new Set();
20
+
21
+ register(key: string, cleanup: Function): void {
22
+ this.cleanupFns.set(key, cleanup);
23
+ }
24
+
25
+ unregister(key: string): void {
26
+ this.cleanupFns.delete(key);
27
+ }
28
+
29
+ async cleanup(): Promise<void> {
30
+ // Wait for all pending operations
31
+ await Promise.all(this.pendingOperations);
32
+
33
+ // Run all cleanup functions
34
+ for (const cleanup of this.cleanupFns.values()) {
35
+ try {
36
+ await cleanup();
37
+ } catch (error) {
38
+ console.error('Error during cleanup:', error);
39
+ }
40
+ }
41
+ this.cleanupFns.clear();
42
+ }
43
+
44
+ async runOperation<T>(operation: () => Promise<T>): Promise<T> {
45
+ const promise = operation();
46
+ const voidPromise = promise.then(() => {}, () => {});
47
+ this.pendingOperations.add(voidPromise);
48
+
49
+ try {
50
+ const result = await promise;
51
+ this.pendingOperations.delete(voidPromise);
52
+ return result;
53
+ } catch (error) {
54
+ this.pendingOperations.delete(voidPromise);
55
+ throw error;
56
+ }
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Decorator to register a controller class with a name
62
+ * @param name The name to register the controller under
63
+ */
64
+ export function controller(name: string) {
65
+ return function <T extends ControllerClass>(constructor: T) {
66
+ snice.controllerRegistry.set(name, constructor);
67
+ // Mark as controller class for channel decorator detection
68
+ (constructor.prototype as any)[IS_CONTROLLER_CLASS] = true;
69
+ return constructor;
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Attaches a controller to an element
75
+ * @param element The element to attach the controller to
76
+ * @param controllerName The name of the controller to attach
77
+ */
78
+ export async function attachController(element: HTMLElement, controllerName: string): Promise<void> {
79
+ const existingController = (element as any)[CONTROLLER_KEY] as IController | undefined;
80
+ const existingName = (element as any)[CONTROLLER_NAME_KEY] as string | undefined;
81
+
82
+ // For native elements, check if this is actually the desired controller
83
+ const nativeController = (element as any)[NATIVE_CONTROLLER];
84
+ if (nativeController !== undefined && nativeController !== controllerName) {
85
+ // This attachment is outdated, skip it
86
+ return;
87
+ }
88
+
89
+ if (existingName === controllerName && existingController) {
90
+ // Already attached and controller exists
91
+ return;
92
+ }
93
+
94
+ // If there's an existing controller, detach it first
95
+ if (existingController) {
96
+ await detachController(element);
97
+ }
98
+
99
+ const ControllerClass = snice.controllerRegistry.get(controllerName);
100
+ if (!ControllerClass) {
101
+ throw new Error(`Controller "${controllerName}" not found in registry`);
102
+ }
103
+
104
+ // Create controller instance with unique ID and scope
105
+ const controllerInstance = new ControllerClass();
106
+ snice.controllerIdCounter += 1;
107
+ const controllerId = snice.controllerIdCounter;
108
+ const scope = new ControllerScope();
109
+
110
+ (controllerInstance as any)[CONTROLLER_ID] = controllerId;
111
+ controllerInstance.element = element;
112
+
113
+ // Store references
114
+ (element as any)[CONTROLLER_KEY] = controllerInstance;
115
+ (element as any)[CONTROLLER_NAME_KEY] = controllerName;
116
+ (element as any)[CONTROLLER_OPERATIONS] = scope;
117
+
118
+ // Wait for element to be ready (required)
119
+ await (element as any).ready;
120
+
121
+ // Run attach in the controller's scope
122
+ await scope.runOperation(async () => {
123
+ await controllerInstance.attach(element);
124
+ });
125
+
126
+ // Setup @on event handlers for controller
127
+ setupEventHandlers(controllerInstance, element);
128
+
129
+ // Setup @channel handlers for controller
130
+ setupChannelHandlers(controllerInstance, element);
131
+
132
+ element.dispatchEvent(new CustomEvent('controller.attached', {
133
+ detail: { name: controllerName, controller: controllerInstance }
134
+ }));
135
+ }
136
+
137
+ /**
138
+ * Detaches a controller from an element
139
+ * @param element The element to detach the controller from
140
+ */
141
+ export async function detachController(element: HTMLElement): Promise<void> {
142
+ const controllerInstance = (element as any)[CONTROLLER_KEY] as IController | undefined;
143
+ const controllerName = (element as any)[CONTROLLER_NAME_KEY] as string | undefined;
144
+ const scope = (element as any)[CONTROLLER_OPERATIONS] as ControllerScope | undefined;
145
+
146
+ if (!controllerInstance) {
147
+ return;
148
+ }
149
+
150
+ // Run detach in the controller's scope
151
+ if (scope) {
152
+ await scope.runOperation(async () => {
153
+ await controllerInstance.detach(element);
154
+ });
155
+ } else {
156
+ await controllerInstance.detach(element);
157
+ }
158
+
159
+ controllerInstance.element = null;
160
+
161
+ // Cleanup @on event handlers for controller
162
+ cleanupEventHandlers(controllerInstance);
163
+
164
+ // Cleanup @channel handlers for controller
165
+ cleanupChannelHandlers(controllerInstance);
166
+
167
+ // Cleanup the controller scope
168
+ if (scope) {
169
+ await scope.cleanup();
170
+ }
171
+
172
+ delete (element as any)[CONTROLLER_KEY];
173
+ delete (element as any)[CONTROLLER_NAME_KEY];
174
+ delete (element as any)[CONTROLLER_OPERATIONS];
175
+
176
+ element.dispatchEvent(new CustomEvent('controller.detached', {
177
+ detail: { name: controllerName, controller: controllerInstance }
178
+ }));
179
+ }
180
+
181
+ /**
182
+ * Gets the controller instance attached to an element
183
+ * @param element The element to get the controller from
184
+ * @returns The controller instance or undefined
185
+ */
186
+ export function getController<T extends IController = IController>(element: HTMLElement): T | undefined {
187
+ return (element as any)[CONTROLLER_KEY] as T | undefined;
188
+ }
189
+
190
+ /**
191
+ * Gets the controller scope for an element
192
+ * @param element The element to get the scope from
193
+ * @returns The controller scope or undefined
194
+ */
195
+ export function getControllerScope(element: HTMLElement): ControllerScope | undefined {
196
+ return (element as any)[CONTROLLER_OPERATIONS] as ControllerScope | undefined;
197
+ }
198
+
199
+ /**
200
+ * Enable controller support for native HTML elements
201
+ * This sets up a MutationObserver to watch for controller attributes
202
+ * on non-custom elements (elements without hyphens in their tag names)
203
+ */
204
+ export function useNativeElementControllers() {
205
+ // Return if already initialized
206
+ if ((globalThis as any).sniceNativeControllersInitialized) {
207
+ return;
208
+ }
209
+ (globalThis as any).sniceNativeControllersInitialized = true;
210
+
211
+ // Process elements that already have controller attribute
212
+ function processElement(element: Element) {
213
+ if (!(element instanceof HTMLElement)) return;
214
+
215
+ // Skip custom elements (they handle controllers themselves)
216
+ if (element.tagName.includes('-')) return;
217
+
218
+ // Skip elements that are @element decorated (they have their own controller handling)
219
+ if ((element as any)[IS_ELEMENT_CLASS]) return;
220
+
221
+ const controllerName = element.getAttribute('controller');
222
+ const currentControllerName = (element as any)[NATIVE_CONTROLLER];
223
+
224
+ if (controllerName && controllerName !== currentControllerName) {
225
+ // Controller added or changed
226
+ (element as any)[NATIVE_CONTROLLER] = controllerName;
227
+
228
+ // For non-custom elements, we need to add the ready promise
229
+ if (!(element as any).ready) {
230
+ (element as any).ready = Promise.resolve();
231
+ }
232
+
233
+ // Detach old controller if exists (don't await - let it run async)
234
+ if (currentControllerName) {
235
+ detachController(element as HTMLElement).catch(error => {
236
+ console.error(`Failed to detach old controller from native element:`, error);
237
+ });
238
+ }
239
+
240
+ // Attach the new controller
241
+ attachController(element as HTMLElement, controllerName).catch(error => {
242
+ console.error(`Failed to attach controller "${controllerName}" to native element:`, error);
243
+ });
244
+ } else if (!controllerName && currentControllerName) {
245
+ // Controller was removed
246
+ delete (element as any)[NATIVE_CONTROLLER];
247
+ // Clear the controller name immediately to allow re-attachment
248
+ delete (element as any)[CONTROLLER_NAME_KEY];
249
+ detachController(element as HTMLElement).catch(error => {
250
+ console.error(`Failed to detach controller from native element:`, error);
251
+ });
252
+ }
253
+ }
254
+
255
+ // Set up MutationObserver to watch for controller attributes
256
+ const observer = new MutationObserver((mutations) => {
257
+ for (const mutation of mutations) {
258
+ if (mutation.type === 'attributes' && mutation.attributeName === 'controller') {
259
+ processElement(mutation.target as Element);
260
+ } else if (mutation.type === 'childList') {
261
+ // Process added nodes
262
+ mutation.addedNodes.forEach(node => {
263
+ if (node instanceof HTMLElement) {
264
+ // Process the node itself
265
+ processElement(node);
266
+ // Process all descendants with controller attribute
267
+ node.querySelectorAll('[controller]:not([class*="-"])').forEach(processElement);
268
+ }
269
+ });
270
+ }
271
+ }
272
+ });
273
+
274
+ // Start observing when DOM is ready
275
+ if (document.readyState === 'loading') {
276
+ document.addEventListener('DOMContentLoaded', () => {
277
+ // Process existing elements (excluding custom elements)
278
+ document.querySelectorAll('[controller]:not([class*="-"])').forEach(processElement);
279
+
280
+ // Start observing
281
+ observer.observe(document.body, {
282
+ attributes: true,
283
+ attributeFilter: ['controller'],
284
+ childList: true,
285
+ subtree: true
286
+ });
287
+ });
288
+ } else {
289
+ // DOM already loaded
290
+ document.querySelectorAll('[controller]:not([class*="-"])').forEach(processElement);
291
+
292
+ observer.observe(document.body, {
293
+ attributes: true,
294
+ attributeFilter: ['controller'],
295
+ childList: true,
296
+ subtree: true
297
+ });
298
+ }
299
+
300
+ // Store observer reference for cleanup if needed
301
+ (globalThis as any).sniceNativeControllerObserver = observer;
302
+ }
303
+
304
+ /**
305
+ * Stop watching for native element controllers
306
+ */
307
+ export function cleanupNativeElementControllers() {
308
+ const observer = (globalThis as any).sniceNativeControllerObserver;
309
+ if (observer) {
310
+ observer.disconnect();
311
+ delete (globalThis as any).sniceNativeControllerObserver;
312
+ delete (globalThis as any).sniceNativeControllersInitialized;
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Registers a cleanup function for the current controller
318
+ * @param controller The controller instance
319
+ * @param key A unique key for this cleanup function
320
+ * @param cleanup The cleanup function to register
321
+ */
322
+ export function registerControllerCleanup(controller: IController, key: string, cleanup: Function): void {
323
+ if (!controller.element) return;
324
+
325
+ const scope = getControllerScope(controller.element as HTMLElement);
326
+ if (scope) {
327
+ scope.register(key, cleanup);
328
+ }
329
+ }