snice 1.9.0 → 1.10.1

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
@@ -44,7 +44,7 @@ Snice provides a clear separation of concerns through decorators:
44
44
  - **`@on`** - Listens for events on elements
45
45
  - **`@dispatch`** - Dispatches custom events after method execution
46
46
  - **`@request`** - Makes requests from elements or controllers
47
- - **`@response`** - Responds to requests in elements or controllers
47
+ - **`@respond`** - Responds to requests in elements or controllers
48
48
 
49
49
  This separation keeps your components focused: elements handle presentation, controllers manage data, and pages define navigation.
50
50
 
@@ -158,6 +158,25 @@ Use it with attributes:
158
158
  <user-card name="Jane Doe" user-role="Admin" verified></user-card>
159
159
  ```
160
160
 
161
+ For arrays of basic types, use `SimpleArray` for safe reflection:
162
+
163
+ ```typescript
164
+ import { element, property, SimpleArray } from 'snice';
165
+
166
+ @element('tag-list')
167
+ class TagList extends HTMLElement {
168
+ @property({ type: SimpleArray, reflect: true })
169
+ tags = ['javascript', 'typescript', 'web'];
170
+
171
+ html() {
172
+ return `<div>${this.tags.join(', ')}</div>`;
173
+ }
174
+ }
175
+ ```
176
+
177
+ ```html
178
+ <tag-list tags="react,vue,angular"></tag-list>
179
+ ```
161
180
 
162
181
  ## Watching Property Changes
163
182
 
@@ -303,6 +322,11 @@ class MyClicker extends HTMLElement {
303
322
  handleCtrlEnter() {
304
323
  console.log('Ctrl + Enter pressed!');
305
324
  }
325
+
326
+ @on('focus') // Listen on the host element itself (no target)
327
+ handleFocus() {
328
+ console.log('Element received focus!');
329
+ }
306
330
  }
307
331
  ```
308
332
 
@@ -566,12 +590,14 @@ Use it:
566
590
  Bidirectional communication between elements and controllers:
567
591
 
568
592
  ```typescript
593
+ import { element, request, type Response } from 'snice';
594
+
569
595
  // Element makes request, controller responds
570
- @element('user-profile')
596
+ @element('user-profile')
571
597
  class UserProfile extends HTMLElement {
572
598
 
573
599
  @request('fetch-user')
574
- async *getUser() {
600
+ async *getUser(): Response<{ name: string; email: string }> {
575
601
  const user = await (yield { userId: 123 });
576
602
  return user;
577
603
  }
@@ -587,7 +613,7 @@ class UserProfile extends HTMLElement {
587
613
  @controller('user-controller')
588
614
  class UserController {
589
615
 
590
- @response('fetch-user')
616
+ @respond('fetch-user')
591
617
  async handleFetchUser(request: { userId: number }) {
592
618
  const response = await fetch(`/api/users/${request.userId}`);
593
619
  return response.json();
@@ -657,7 +683,7 @@ class ProfilePage extends HTMLElement {
657
683
 
658
684
  ### Context in Nested Elements
659
685
 
660
- Nested elements within pages can also access context through event bubbling:
686
+ Nested elements within pages can also access context:
661
687
 
662
688
  ```typescript
663
689
  // This element can be used inside any page
@@ -765,6 +791,119 @@ class LazyImage extends HTMLElement {
765
791
 
766
792
  All observers are automatically cleaned up when elements disconnect from the DOM. See the [Observe API documentation](./docs/observe.md) for more examples.
767
793
 
794
+ ## Parts - Selective Re-rendering
795
+
796
+ For complex components with frequent updates to specific sections, the `@part` decorator enables selective re-rendering of template parts without rebuilding the entire component:
797
+
798
+ ```typescript
799
+ import { element, part, property, on } from 'snice';
800
+
801
+ @element('user-dashboard')
802
+ class UserDashboard extends HTMLElement {
803
+ @property()
804
+ user = { name: 'Loading...', stats: { views: 0, likes: 0 } };
805
+
806
+ notifications = [];
807
+ messages = [];
808
+
809
+ html() {
810
+ return `
811
+ <header part="user-info"></header>
812
+ <main>
813
+ <section part="stats"></section>
814
+ <aside part="notifications"></aside>
815
+ <div part="messages"></div>
816
+ </main>
817
+ `;
818
+ }
819
+
820
+ @part('user-info')
821
+ renderUserInfo() {
822
+ return `
823
+ <h1>${this.user.name}</h1>
824
+ <button id="refresh-user">Refresh</button>
825
+ `;
826
+ }
827
+
828
+ @part('stats')
829
+ renderStats() {
830
+ return `
831
+ <div class="stats">
832
+ <span>Views: ${this.user.stats.views}</span>
833
+ <span>Likes: ${this.user.stats.likes}</span>
834
+ </div>
835
+ `;
836
+ }
837
+
838
+ @part('notifications', { throttle: 300 })
839
+ renderNotifications() {
840
+ return `
841
+ <h3>Notifications (${this.notifications.length})</h3>
842
+ ${this.notifications.map(n => `<div>${n}</div>`).join('')}
843
+ `;
844
+ }
845
+
846
+ @part('messages')
847
+ async renderMessages() {
848
+ if (this.messages.length === 0) {
849
+ return '<p>No messages</p>';
850
+ }
851
+ return this.messages.map(m => `<div class="message">${m}</div>`).join('');
852
+ }
853
+
854
+ // Update specific parts without re-rendering everything
855
+ updateUserName(newName) {
856
+ this.user.name = newName;
857
+ this.renderUserInfo(); // Only re-renders the header
858
+ }
859
+
860
+ incrementViews() {
861
+ this.user.stats.views++;
862
+ this.renderStats(); // Only re-renders the stats section
863
+ }
864
+
865
+ addNotification(notification) {
866
+ this.notifications.unshift(notification);
867
+ this.renderNotifications(); // Only re-renders notifications
868
+ }
869
+
870
+ @on('click', '#refresh-user')
871
+ async handleRefreshUser() {
872
+ // Simulate API call
873
+ const userData = await this.fetchUserData();
874
+ this.user = userData;
875
+ this.renderUserInfo(); // Update just the user info part
876
+ this.renderStats(); // Update just the stats part
877
+ }
878
+ }
879
+ ```
880
+
881
+ **Benefits of `@part`:**
882
+ - **Performance** - Update only what changed instead of re-rendering entire templates
883
+ - **Granular Control** - Target specific sections for updates
884
+ - **Complex UIs** - Perfect for dashboards, lists, or components with independent sections
885
+ - **Async Support** - Part methods can be async for data fetching
886
+ - **Throttle/Debounce** - Control render frequency to optimize performance
887
+
888
+ ### Performance Options
889
+
890
+ The `@part` decorator supports throttle and debounce options to optimize render performance:
891
+
892
+ ```typescript
893
+ // Throttle: Limit renders to once per 300ms
894
+ @part('notifications', { throttle: 300 })
895
+ renderNotifications() { /* ... */ }
896
+
897
+ // Debounce: Delay render until 150ms after last call
898
+ @part('search-results', { debounce: 150 })
899
+ renderSearchResults() { /* ... */ }
900
+ ```
901
+
902
+ - **Throttle** - Limits renders to a maximum frequency (e.g., once every 300ms)
903
+ - **Debounce** - Delays renders until after calls stop for the specified time
904
+
905
+ The `@part` decorator is ideal when you have components with multiple independent sections that update at different frequencies or from different data sources.
906
+
768
907
  ## Documentation
769
908
 
770
909
  - [Elements API](./docs/elements.md) - Complete guide to creating elements with properties, queries, and styling
@@ -1,5 +1,5 @@
1
1
  import { element, property, query, on, dispatch } from 'snice';
2
- import type { ICounterButton } from '../types/counter-button';
2
+ import type { ICounterButton } from './counter-button.types';
3
3
 
4
4
  @element('counter-button')
5
5
  export class CounterButton extends HTMLElement implements ICounterButton {
@@ -1,5 +1,5 @@
1
1
  import { controller, on } from 'snice';
2
- import type { ICounterButton } from '../types/counter-button';
2
+ import type { ICounterButton } from '../components/counter-button.types';
3
3
 
4
4
  @controller('counter')
5
5
  export class CounterController {
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "snice",
3
- "version": "1.9.0",
3
+ "version": "1.10.1",
4
4
  "type": "module",
5
- "description": "A TypeScript library",
5
+ "description": "Imperative TypeScript framework for building vanilla web components with decorators, routing, and controllers. No virtual DOM, no build complexity.",
6
6
  "main": "src/index.ts",
7
7
  "module": "src/index.ts",
8
8
  "types": "src/index.ts",
@@ -16,6 +16,40 @@
16
16
  "!src/**/*.test.ts",
17
17
  "!src/**/*.spec.ts"
18
18
  ],
19
+ "keywords": [
20
+ "web components",
21
+ "typescript",
22
+ "framework",
23
+ "decorators",
24
+ "custom elements",
25
+ "vanilla js",
26
+ "frontend",
27
+ "spa",
28
+ "routing",
29
+ "imperative",
30
+ "no virtual dom",
31
+ "lightweight",
32
+ "minimal",
33
+ "controllers",
34
+ "mvc",
35
+ "shadow dom",
36
+ "lit alternative",
37
+ "stencil alternative",
38
+ "web standards"
39
+ ],
40
+ "author": "hedzer",
41
+ "license": "MIT",
42
+ "homepage": "https://gitlab.com/Hedzer/snice#readme",
43
+ "repository": {
44
+ "type": "git",
45
+ "url": "git@gitlab.com:Hedzer/snice.git"
46
+ },
47
+ "bugs": {
48
+ "url": "https://gitlab.com/Hedzer/snice/-/issues"
49
+ },
50
+ "engines": {
51
+ "node": ">=18.0.0"
52
+ },
19
53
  "publishConfig": {
20
54
  "access": "public"
21
55
  },
package/src/controller.ts CHANGED
@@ -141,7 +141,7 @@ export async function attachController(element: HTMLElement, controllerName: str
141
141
  // Setup @channel handlers for controller
142
142
  setupResponseHandlers(controllerInstance, element);
143
143
 
144
- element.dispatchEvent(new CustomEvent('controller.attached', {
144
+ element.dispatchEvent(new CustomEvent('@snice/controller-attached', {
145
145
  detail: { name: controllerName, controller: controllerInstance }
146
146
  }));
147
147
  }
@@ -191,7 +191,7 @@ export async function detachController(element: HTMLElement): Promise<void> {
191
191
  delete (element as any)[CONTROLLER_NAME_KEY];
192
192
  delete (element as any)[CONTROLLER_OPERATIONS];
193
193
 
194
- element.dispatchEvent(new CustomEvent('controller.detached', {
194
+ element.dispatchEvent(new CustomEvent('@snice/controller-detached', {
195
195
  detail: { name: controllerName, controller: controllerInstance }
196
196
  }));
197
197
  }
package/src/element.ts CHANGED
@@ -2,7 +2,7 @@ import { attachController, detachController } from './controller';
2
2
  import { setupEventHandlers, cleanupEventHandlers } from './events';
3
3
  import { setupObservers, cleanupObservers } from './observe';
4
4
  import { setupResponseHandlers, cleanupResponseHandlers } from './request-response';
5
- import { IS_ELEMENT_CLASS, IS_CONTROLLER_INSTANCE, READY_PROMISE, READY_RESOLVE, CONTROLLER, PROPERTIES, PROPERTY_VALUES, PROPERTIES_INITIALIZED, PROPERTY_WATCHERS, EXPLICITLY_SET_PROPERTIES, ROUTER_CONTEXT, READY_HANDLERS, DISPOSE_HANDLERS } from './symbols';
5
+ import { IS_ELEMENT_CLASS, IS_CONTROLLER_INSTANCE, READY_PROMISE, READY_RESOLVE, CONTROLLER, PROPERTIES, PROPERTY_VALUES, PROPERTIES_INITIALIZED, PROPERTY_WATCHERS, EXPLICITLY_SET_PROPERTIES, ROUTER_CONTEXT, READY_HANDLERS, DISPOSE_HANDLERS, PARTS, PART_TIMERS } from './symbols';
6
6
 
7
7
  /**
8
8
  * Applies core element functionality to a constructor
@@ -27,7 +27,7 @@ export function applyElementFunctionality(constructor: any) {
27
27
  const properties = constructor[PROPERTIES];
28
28
  if (properties) {
29
29
  for (const [propName, propOptions] of properties) {
30
- const attributeName = typeof propOptions.attribute === 'string' ? propOptions.attribute : propName;
30
+ const attributeName = typeof propOptions.attribute === 'string' ? propOptions.attribute : propName.toLowerCase();
31
31
  if (!observedAttributes.includes(attributeName)) {
32
32
  observedAttributes.push(attributeName);
33
33
  }
@@ -78,6 +78,7 @@ export function applyElementFunctionality(constructor: any) {
78
78
  configurable: true
79
79
  });
80
80
 
81
+
81
82
  constructor.prototype.connectedCallback = async function() {
82
83
  // If ready promise was already created (controller attached before connected), use existing resolve
83
84
  // Otherwise create the ready promise now
@@ -113,6 +114,19 @@ export function applyElementFunctionality(constructor: any) {
113
114
  case String:
114
115
  this[propName] = attrValue;
115
116
  break;
117
+ case Date:
118
+ this[propName] = attrValue ? new Date(attrValue) : null;
119
+ break;
120
+ case BigInt:
121
+ if (attrValue && attrValue.endsWith('n')) {
122
+ this[propName] = BigInt(attrValue.slice(0, -1));
123
+ } else {
124
+ this[propName] = attrValue ? BigInt(attrValue) : null;
125
+ }
126
+ break;
127
+ case SimpleArray:
128
+ this[propName] = SimpleArray.parse(attrValue);
129
+ break;
116
130
  default:
117
131
  this[propName] = attrValue;
118
132
  }
@@ -129,10 +143,22 @@ export function applyElementFunctionality(constructor: any) {
129
143
  for (const [propName, propOptions] of properties) {
130
144
  if (propOptions.reflect && this[EXPLICITLY_SET_PROPERTIES].has(propName) && propName in this[PROPERTY_VALUES]) {
131
145
  const value = this[PROPERTY_VALUES][propName];
132
- const attributeName = typeof propOptions.attribute === 'string' ? propOptions.attribute : propName;
146
+ const attributeName = typeof propOptions.attribute === 'string' ? propOptions.attribute : propName.toLowerCase();
133
147
 
134
- if (value !== null && value !== undefined && value !== false) {
135
- this.setAttribute(attributeName, String(value));
148
+ if (value !== null && value !== undefined && value !== false &&
149
+ !(propOptions.type === SimpleArray && Array.isArray(value) && value.length === 0)) {
150
+ // Handle special types for reflection
151
+ let attributeValue: string;
152
+ if (value instanceof Date) {
153
+ attributeValue = value.toISOString();
154
+ } else if (typeof value === 'bigint') {
155
+ attributeValue = value.toString() + 'n';
156
+ } else if (propOptions.type === SimpleArray && Array.isArray(value)) {
157
+ attributeValue = SimpleArray.serialize(value);
158
+ } else {
159
+ attributeValue = String(value);
160
+ }
161
+ this.setAttribute(attributeName, attributeValue);
136
162
  }
137
163
  }
138
164
  }
@@ -185,6 +211,25 @@ export function applyElementFunctionality(constructor: any) {
185
211
  this.shadowRoot.innerHTML = shadowContent;
186
212
  }
187
213
 
214
+ // Render all @part methods into their corresponding elements
215
+ const parts = constructor[PARTS];
216
+ if (parts && this.shadowRoot) {
217
+ for (const [partName, partHandler] of parts) {
218
+ try {
219
+ const partElement = this.shadowRoot.querySelector(`[part="${partName}"]`);
220
+ if (partElement) {
221
+ const partResult = partHandler.method.call(this);
222
+ const partContent = partResult instanceof Promise ? await partResult : partResult;
223
+ if (partContent !== undefined) {
224
+ partElement.innerHTML = partContent;
225
+ }
226
+ }
227
+ } catch (error) {
228
+ console.error(`Error rendering @part('${partName}') in ${this.tagName}:`, error);
229
+ }
230
+ }
231
+ }
232
+
188
233
  // NOW call the original user-defined connectedCallback after shadow DOM is set up
189
234
  if (originalConnectedCallback) {
190
235
  originalConnectedCallback.call(this);
@@ -197,7 +242,7 @@ export function applyElementFunctionality(constructor: any) {
197
242
  // Setup @on event handlers - use element for host events, shadow root for delegated events
198
243
  setupEventHandlers(this, this);
199
244
 
200
- // Setup @response handlers for elements
245
+ // Setup @respond handlers for elements
201
246
  setupResponseHandlers(this, this);
202
247
 
203
248
  // Setup @observe observers
@@ -251,7 +296,7 @@ export function applyElementFunctionality(constructor: any) {
251
296
  }
252
297
  // Cleanup @on event handlers
253
298
  cleanupEventHandlers(this);
254
- // Cleanup @response handlers
299
+ // Cleanup @respond handlers
255
300
  cleanupResponseHandlers(this);
256
301
  // Cleanup @observe observers
257
302
  cleanupObservers(this);
@@ -266,7 +311,7 @@ export function applyElementFunctionality(constructor: any) {
266
311
  const properties = constructor[PROPERTIES];
267
312
  if (properties) {
268
313
  for (const [propName, propOptions] of properties) {
269
- const attributeName = typeof propOptions.attribute === 'string' ? propOptions.attribute : propName;
314
+ const attributeName = typeof propOptions.attribute === 'string' ? propOptions.attribute : propName.toLowerCase();
270
315
  if (attributeName === name) {
271
316
  // Check if the current property value already matches to avoid feedback loops
272
317
  const currentValue = this[PROPERTY_VALUES]?.[propName];
@@ -277,6 +322,16 @@ export function applyElementFunctionality(constructor: any) {
277
322
  parsedValue = newValue !== null && newValue !== 'false';
278
323
  } else if (propOptions.type === Number) {
279
324
  parsedValue = Number(newValue);
325
+ } else if (propOptions.type === Date) {
326
+ parsedValue = newValue ? new Date(newValue) : null;
327
+ } else if (propOptions.type === BigInt) {
328
+ if (newValue && newValue.endsWith('n')) {
329
+ parsedValue = BigInt(newValue.slice(0, -1));
330
+ } else {
331
+ parsedValue = newValue ? BigInt(newValue) : null;
332
+ }
333
+ } else if (propOptions.type === SimpleArray) {
334
+ parsedValue = SimpleArray.parse(newValue);
280
335
  } else {
281
336
  // If no type specified, try to infer from current value type
282
337
  if (typeof currentValue === 'number' && newValue !== null) {
@@ -286,7 +341,7 @@ export function applyElementFunctionality(constructor: any) {
286
341
  }
287
342
  }
288
343
 
289
- // Only update if the value actually changed
344
+ // Only update if the value actually changed and avoid infinite loops
290
345
  if (currentValue !== parsedValue) {
291
346
  // Mark as explicitly set since it came from an attribute change
292
347
  if (!this[EXPLICITLY_SET_PROPERTIES]) {
@@ -294,7 +349,39 @@ export function applyElementFunctionality(constructor: any) {
294
349
  }
295
350
  this[EXPLICITLY_SET_PROPERTIES].add(propName);
296
351
 
297
- this[propName] = parsedValue;
352
+ // Set the property value directly in the storage to avoid triggering setter
353
+ if (!this[PROPERTY_VALUES]) {
354
+ this[PROPERTY_VALUES] = {};
355
+ }
356
+ this[PROPERTY_VALUES][propName] = parsedValue;
357
+
358
+ // Call watchers manually since we bypassed the setter
359
+ const watchers = constructor[PROPERTY_WATCHERS];
360
+ if (watchers) {
361
+ // Call specific property watchers
362
+ if (watchers.has(propName)) {
363
+ const propertyWatchers = watchers.get(propName);
364
+ for (const watcher of propertyWatchers) {
365
+ try {
366
+ watcher.method.call(this, currentValue, parsedValue, propName);
367
+ } catch (error) {
368
+ console.error(`Error in @watch('${propName}') method ${watcher.methodName}:`, error);
369
+ }
370
+ }
371
+ }
372
+
373
+ // Call wildcard watchers (watching "*")
374
+ if (watchers.has('*')) {
375
+ const wildcardWatchers = watchers.get('*');
376
+ for (const watcher of wildcardWatchers) {
377
+ try {
378
+ watcher.method.call(this, currentValue, parsedValue, propName);
379
+ } catch (error) {
380
+ console.error(`Error in @watch('*') method ${watcher.methodName}:`, error);
381
+ }
382
+ }
383
+ }
384
+ }
298
385
  }
299
386
  break;
300
387
  }
@@ -311,13 +398,19 @@ export function element(tagName: string) {
311
398
  };
312
399
  }
313
400
 
314
- // Alias for backwards compatibility
315
- export const customElement = element;
316
-
317
401
  export function property(options?: PropertyOptions) {
318
402
  return function (target: any, propertyKey: string) {
319
403
  const constructor = target.constructor;
320
404
 
405
+ // Warn about problematic reflection usage
406
+ if (options?.reflect && options?.type === Array) {
407
+ console.warn(`⚠️ Property '${propertyKey}' uses reflect:true with Array type.`);
408
+ }
409
+
410
+ if (options?.reflect && options?.type === Object) {
411
+ console.warn(`⚠️ Property '${propertyKey}' uses reflect:true with Object type.`);
412
+ }
413
+
321
414
  if (!constructor[PROPERTIES]) {
322
415
  constructor[PROPERTIES] = new Map();
323
416
  }
@@ -344,9 +437,12 @@ export function property(options?: PropertyOptions) {
344
437
  // Don't update if value hasn't changed
345
438
  if (oldValue === value) return;
346
439
 
347
- // Only mark as explicitly set if there was a previous value
348
- // (i.e., this is not the initial default value being set during class initialization)
349
- if (oldValue !== undefined) {
440
+ // Mark as explicitly set in these cases:
441
+ // 1. There was a previous value (normal property update)
442
+ // 2. This is during element construction and we have a non-null/non-undefined value
443
+ // (this handles default values declared in class properties)
444
+ const isInitialDefaultValue = oldValue === undefined && !this[PROPERTIES_INITIALIZED];
445
+ if (oldValue !== undefined || (isInitialDefaultValue && value !== null && value !== undefined)) {
350
446
  this[EXPLICITLY_SET_PROPERTIES].add(propertyKey);
351
447
  }
352
448
 
@@ -357,12 +453,24 @@ export function property(options?: PropertyOptions) {
357
453
  // 2. The property was explicitly set (not just default value)
358
454
  // This prevents default values from creating attributes
359
455
  if (options?.reflect && this.setAttribute && this[PROPERTIES_INITIALIZED] && this[EXPLICITLY_SET_PROPERTIES].has(propertyKey)) {
360
- const attributeName = typeof options.attribute === 'string' ? options.attribute : propertyKey;
456
+ const attributeName = typeof options.attribute === 'string' ? options.attribute : propertyKey.toLowerCase();
361
457
 
362
- if (value === null || value === undefined || value === false) {
458
+ if (value === null || value === undefined || value === false ||
459
+ (options?.type === SimpleArray && Array.isArray(value) && value.length === 0)) {
363
460
  this.removeAttribute(attributeName);
364
461
  } else {
365
- this.setAttribute(attributeName, String(value));
462
+ // Handle special types for reflection
463
+ let attributeValue: string;
464
+ if (value instanceof Date) {
465
+ attributeValue = value.toISOString();
466
+ } else if (typeof value === 'bigint') {
467
+ attributeValue = value.toString() + 'n';
468
+ } else if (options?.type === SimpleArray && Array.isArray(value)) {
469
+ attributeValue = SimpleArray.serialize(value);
470
+ } else {
471
+ attributeValue = String(value);
472
+ }
473
+ this.setAttribute(attributeName, attributeValue);
366
474
  }
367
475
  }
368
476
 
@@ -477,8 +585,64 @@ export function queryAll(selector: string, options: QueryOptions = {}) {
477
585
  };
478
586
  }
479
587
 
588
+ /**
589
+ * SimpleArray type for arrays that can be safely reflected to attributes
590
+ * Supports arrays of: string, number, boolean
591
+ * Uses full-width comma (,) as separator to avoid conflicts
592
+ * Strings cannot contain the full-width comma character
593
+ */
594
+ export class SimpleArray {
595
+ static readonly SEPARATOR = ','; // U+FF0C Full-width comma
596
+
597
+ /**
598
+ * Serialize array to string for attribute storage
599
+ */
600
+ static serialize(arr: (string | number | boolean)[]): string {
601
+ if (!Array.isArray(arr)) return '';
602
+
603
+ return arr.map(item => {
604
+ if (typeof item === 'string') {
605
+ // Validate string doesn't contain our separator
606
+ if (item.includes(SimpleArray.SEPARATOR)) {
607
+ throw new Error(`SimpleArray strings cannot contain the character "${SimpleArray.SEPARATOR}" (U+FF0C)`);
608
+ }
609
+ return item;
610
+ } else if (typeof item === 'number' || typeof item === 'boolean') {
611
+ return String(item);
612
+ } else {
613
+ throw new Error(`SimpleArray only supports string, number, and boolean types. Got: ${typeof item}`);
614
+ }
615
+ }).join(SimpleArray.SEPARATOR);
616
+ }
617
+
618
+ /**
619
+ * Parse string from attribute back to array
620
+ */
621
+ static parse(str: string | null): (string | number | boolean)[] {
622
+ if (str === null || str === undefined) return [];
623
+ // Empty string should not be parsed as containing an empty string
624
+ // since empty arrays don't get reflected (handled by the reflection logic)
625
+ if (str === '') return [];
626
+
627
+ return str.split(SimpleArray.SEPARATOR).map(item => {
628
+ // Try to parse as number
629
+ if (/^-?\d+\.?\d*$/.test(item)) {
630
+ const num = Number(item);
631
+ if (!isNaN(num)) return num;
632
+ }
633
+
634
+ // Parse as boolean
635
+ if (item === 'true') return true;
636
+ if (item === 'false') return false;
637
+
638
+ // Default to string
639
+ return item;
640
+ });
641
+ }
642
+ }
643
+
480
644
  export interface PropertyOptions {
481
- type?: StringConstructor | NumberConstructor | BooleanConstructor | ArrayConstructor | ObjectConstructor;
645
+ type?: StringConstructor | NumberConstructor | BooleanConstructor | ArrayConstructor | ObjectConstructor | DateConstructor | BigIntConstructor | typeof SimpleArray;
482
646
  reflect?: boolean;
483
647
  attribute?: string | boolean;
484
648
  converter?: PropertyConverter;
@@ -490,6 +654,20 @@ export interface PropertyConverter {
490
654
  toAttribute?(value: any, type?: any): string | null;
491
655
  }
492
656
 
657
+ /**
658
+ * Interface for Snice elements with all the framework-provided properties and methods
659
+ */
660
+ export interface SniceElement extends HTMLElement {
661
+ ready: Promise<void>;
662
+ html?(): string | Promise<string>;
663
+ css?(): string | string[] | Promise<string | string[]>;
664
+ }
665
+
666
+ export interface PartOptions {
667
+ throttle?: number; // Throttle in milliseconds - limits calls to once per interval
668
+ debounce?: number; // Debounce in milliseconds - delays execution until after calls stop
669
+ }
670
+
493
671
  export function watch(...propertyNames: string[]) {
494
672
  return function (target: any, methodName: string, descriptor: PropertyDescriptor) {
495
673
  const constructor = target.constructor;
@@ -608,6 +786,105 @@ export function dispose() {
608
786
  method: descriptor.value
609
787
  });
610
788
 
789
+ return descriptor;
790
+ };
791
+ }
792
+
793
+ /**
794
+ * Decorator for methods that render specific parts of the template
795
+ * Parts are identified by the 'part' attribute in the HTML template
796
+ * When the decorated method is called, it automatically re-renders its part
797
+ */
798
+ export function part(partName: string, options: PartOptions = {}) {
799
+ return function (target: any, methodName: string, descriptor: PropertyDescriptor) {
800
+ const constructor = target.constructor;
801
+ const originalMethod = descriptor.value;
802
+
803
+ if (!constructor[PARTS]) {
804
+ constructor[PARTS] = new Map();
805
+ }
806
+
807
+ constructor[PARTS].set(partName, {
808
+ methodName,
809
+ method: originalMethod
810
+ });
811
+
812
+ // Wrap the original method to automatically re-render the part when called
813
+ descriptor.value = async function (this: HTMLElement, ...args: any[]) {
814
+ // Initialize timers storage if not present
815
+ if (!(this as any)[PART_TIMERS]) {
816
+ (this as any)[PART_TIMERS] = new Map();
817
+ }
818
+
819
+ // Get or create timers for this specific part
820
+ if (!(this as any)[PART_TIMERS].has(partName)) {
821
+ (this as any)[PART_TIMERS].set(partName, {
822
+ throttleTimer: null,
823
+ debounceTimer: null,
824
+ lastThrottleCall: 0
825
+ });
826
+ }
827
+
828
+ const timers = (this as any)[PART_TIMERS].get(partName);
829
+
830
+ // Create the render function
831
+ const renderPart = async () => {
832
+ // Call the original method to get the content
833
+ const result = originalMethod.apply(this, args);
834
+ const content = result instanceof Promise ? await result : result;
835
+
836
+ // Re-render the part if shadow DOM exists and content is defined
837
+ if (this.shadowRoot && content !== undefined) {
838
+ const partElement = this.shadowRoot.querySelector(`[part="${partName}"]`);
839
+ if (partElement) {
840
+ partElement.innerHTML = content;
841
+ }
842
+ }
843
+
844
+ return content;
845
+ };
846
+
847
+ // Handle debounce (only if positive value)
848
+ if (options.debounce !== undefined && options.debounce > 0) {
849
+ if (timers.debounceTimer) {
850
+ clearTimeout(timers.debounceTimer);
851
+ }
852
+
853
+ return new Promise((resolve) => {
854
+ timers.debounceTimer = setTimeout(async () => {
855
+ const result = await renderPart();
856
+ resolve(result);
857
+ }, options.debounce);
858
+ });
859
+ }
860
+
861
+ // Handle throttle (only if positive value)
862
+ if (options.throttle !== undefined && options.throttle > 0) {
863
+ const now = Date.now();
864
+
865
+ if (timers.lastThrottleCall === 0 || now - timers.lastThrottleCall >= options.throttle) {
866
+ timers.lastThrottleCall = now;
867
+ return await renderPart();
868
+ } else {
869
+ // If throttled, schedule the next call if not already scheduled
870
+ if (!timers.throttleTimer) {
871
+ const remainingTime = options.throttle - (now - timers.lastThrottleCall);
872
+ timers.throttleTimer = setTimeout(async () => {
873
+ timers.throttleTimer = null;
874
+ timers.lastThrottleCall = Date.now();
875
+ await renderPart();
876
+ }, remainingTime);
877
+ }
878
+ // For throttled calls, don't execute the original method, just return undefined
879
+ // The actual render will happen in the scheduled timeout
880
+ return undefined;
881
+ }
882
+ }
883
+
884
+ // No throttle/debounce - render immediately
885
+ return await renderPart();
886
+ };
887
+
611
888
  return descriptor;
612
889
  };
613
890
  }
package/src/index.ts CHANGED
@@ -1,14 +1,14 @@
1
- export { element, customElement, property, query, queryAll, watch, context, applyElementFunctionality, ready, dispose } from './element';
1
+ export { element, property, query, queryAll, watch, context, applyElementFunctionality, ready, dispose, part, SimpleArray } from './element';
2
2
  export { Router } from './router';
3
3
  export { controller, attachController, detachController, getController, useNativeElementControllers, cleanupNativeElementControllers } from './controller';
4
4
  export { on, dispatch } from './events';
5
5
  export { observe } from './observe';
6
- export { request, response } from './request-response';
6
+ export { request, respond } from './request-response';
7
7
  export { IS_CONTROLLER_INSTANCE } from './symbols';
8
8
  export type { Transition } from './transitions';
9
- export type { PropertyOptions, PropertyConverter, QueryOptions } from './element';
9
+ export type { PropertyOptions, PropertyConverter, QueryOptions, SniceElement, PartOptions } from './element';
10
10
  export type { RouterOptions, PageOptions, Guard, RouteParams, RouterInstance } from './router';
11
11
  export type { IController, ControllerClass } from './controller';
12
12
  export type { DispatchOptions } from './events';
13
13
  export type { ObserveOptions } from './observe';
14
- export type { RequestOptions } from './request-response';
14
+ export type { RequestOptions, Response } from './request-response';
@@ -1,10 +1,29 @@
1
1
  import { CHANNEL_HANDLERS, CLEANUP, IS_CONTROLLER_INSTANCE } from './symbols';
2
2
 
3
+ // Pragmatic type for @request decorated methods
4
+ // - Eliminates TypeScript implicit 'any' warnings
5
+ // - Allows property access on returned values (result.data works)
6
+ // - Documents the expected return type T
7
+ // - The @request decorator transforms async generators to return Promise<T> at runtime
8
+ export type Response<T = any> = T | any;
9
+
3
10
  export interface RequestOptions extends EventInit {
4
11
  /**
5
- * Timeout for waiting for responses (in ms)
12
+ * Timeout for waiting for responses (in ms) - defaults to 5000ms
6
13
  */
7
14
  timeout?: number;
15
+ /**
16
+ * Timeout for finding a handler (in ms) - defaults to 50ms
17
+ */
18
+ discoveryTimeout?: number;
19
+ /**
20
+ * Debounce the request by specified milliseconds
21
+ */
22
+ debounce?: number;
23
+ /**
24
+ * Throttle the request by specified milliseconds
25
+ */
26
+ throttle?: number;
8
27
  }
9
28
 
10
29
  /**
@@ -18,11 +37,18 @@ export function request(requestName: string, options?: RequestOptions) {
18
37
  return function (_target: any, _propertyKey: string, descriptor: PropertyDescriptor) {
19
38
  const originalMethod = descriptor.value;
20
39
 
40
+ // Create timing variables for debounce/throttle
41
+ let debounceTimeout: any;
42
+ let throttleLastCall = 0;
43
+ let throttleTimeout: any;
44
+
21
45
  descriptor.value = async function (this: any, ...args: any[]) {
22
- // @request always acts as requester (client side)
23
- const timeout = options?.timeout ?? 100; // Default 100ms timeout
24
-
25
- // Create the generator
46
+ const actualRequest = async () => {
47
+ // @request always acts as requester (client side)
48
+ const responseTimeout = options?.timeout ?? 120000; // Default 2 minute timeout
49
+ const discoveryTimeout = options?.discoveryTimeout ?? 50; // Default 50ms discovery timeout
50
+
51
+ // Create the generator
26
52
  const generator = originalMethod.apply(this, args);
27
53
 
28
54
  // Get the first yield (the request payload)
@@ -41,16 +67,16 @@ export function request(requestName: string, options?: RequestOptions) {
41
67
  dataReject = reject;
42
68
  });
43
69
 
44
- // Create timeout promise and expose resolve/reject
45
- let timeoutResolve: () => void;
46
- let timeoutReject: (reason?: any) => void;
47
- let timeoutId: NodeJS.Timeout;
48
- const timeoutPromise = new Promise<void>((resolve, reject) => {
49
- timeoutResolve = resolve;
50
- timeoutReject = reject;
51
- timeoutId = setTimeout(() => {
52
- reject(new Error(`Request timeout after ${timeout}ms`));
53
- }, timeout);
70
+ // Create discovery timeout promise and expose resolve/reject
71
+ let discoveryResolve: () => void;
72
+ let discoveryReject: (reason?: any) => void;
73
+ let discoveryTimeoutId: NodeJS.Timeout;
74
+ const discoveryPromise = new Promise<void>((resolve, reject) => {
75
+ discoveryResolve = resolve;
76
+ discoveryReject = reject;
77
+ discoveryTimeoutId = setTimeout(() => {
78
+ reject(new Error(`Request "${requestName}" timed out after ${discoveryTimeout}ms - no handler found`));
79
+ }, discoveryTimeout);
54
80
  });
55
81
 
56
82
  // Dispatch event with promises
@@ -61,12 +87,12 @@ export function request(requestName: string, options?: RequestOptions) {
61
87
  composed: true, // Allow crossing shadow DOM boundaries
62
88
  detail: {
63
89
  payload,
64
- timeout: {
90
+ discovery: {
65
91
  resolve: () => {
66
- clearTimeout(timeoutId);
67
- timeoutResolve();
92
+ clearTimeout(discoveryTimeoutId);
93
+ discoveryResolve();
68
94
  },
69
- reject: timeoutReject!
95
+ reject: discoveryReject!
70
96
  },
71
97
  data: {
72
98
  resolve: dataResolve!,
@@ -81,10 +107,17 @@ export function request(requestName: string, options?: RequestOptions) {
81
107
  dispatcher.dispatchEvent(event);
82
108
 
83
109
  try {
84
- // Wait for timeout to be resolved or rejected
85
- await timeoutPromise;
86
- // If we get here, responder responded in time
110
+ // Wait for discovery timeout to be cleared (handler found) or discovery timeout to reject (no handler)
111
+ await discoveryPromise;
112
+
113
+ // If we get here, a handler was found and discovery timeout was cleared
114
+ // Now wait for the actual data response with the full response timeout
115
+ const responseTimeoutId = setTimeout(() => {
116
+ dataReject!(new Error(`Request "${requestName}" timed out after ${responseTimeout}ms`));
117
+ }, responseTimeout);
118
+
87
119
  const response = await dataPromise;
120
+ clearTimeout(responseTimeoutId);
88
121
 
89
122
  // Send response back to generator and get final return value
90
123
  const { value: finalValue } = await generator.next(response);
@@ -97,18 +130,76 @@ export function request(requestName: string, options?: RequestOptions) {
97
130
  throw generatorError;
98
131
  }
99
132
  }
133
+ }; // Close actualRequest function
134
+
135
+ // Apply debounce or throttle if specified
136
+ if (options?.debounce) {
137
+ return new Promise((resolve, reject) => {
138
+ clearTimeout(debounceTimeout);
139
+ debounceTimeout = setTimeout(async () => {
140
+ try {
141
+ const result = await actualRequest();
142
+ resolve(result);
143
+ } catch (error) {
144
+ reject(error);
145
+ }
146
+ }, options.debounce);
147
+ });
148
+ }
149
+
150
+ if (options?.throttle) {
151
+ const now = Date.now();
152
+ const remaining = options.throttle - (now - throttleLastCall);
153
+
154
+ if (remaining <= 0) {
155
+ clearTimeout(throttleTimeout);
156
+ throttleLastCall = now;
157
+ return actualRequest();
158
+ } else if (!throttleTimeout) {
159
+ return new Promise((resolve, reject) => {
160
+ throttleTimeout = setTimeout(async () => {
161
+ throttleLastCall = Date.now();
162
+ throttleTimeout = null;
163
+ try {
164
+ const result = await actualRequest();
165
+ resolve(result);
166
+ } catch (error) {
167
+ reject(error);
168
+ }
169
+ }, remaining);
170
+ });
171
+ }
172
+
173
+ // If throttled and timeout already exists, return empty promise
174
+ return Promise.resolve();
175
+ }
176
+
177
+ // No timing applied, execute immediately
178
+ return actualRequest();
100
179
  };
101
180
 
102
181
  return descriptor;
103
182
  };
104
183
  }
105
184
 
185
+ export interface RespondOptions {
186
+ /**
187
+ * Debounce the response by specified milliseconds
188
+ */
189
+ debounce?: number;
190
+ /**
191
+ * Throttle the response by specified milliseconds
192
+ */
193
+ throttle?: number;
194
+ }
195
+
106
196
  /**
107
197
  * Decorator for responding to requests in elements or controllers.
108
198
  *
109
199
  * @param requestName The name of the request to respond to
200
+ * @param options Optional configuration
110
201
  */
111
- export function response(requestName: string) {
202
+ export function respond(requestName: string, options?: RespondOptions) {
112
203
  return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
113
204
  const originalMethod = descriptor.value;
114
205
 
@@ -121,7 +212,8 @@ export function response(requestName: string) {
121
212
  target[CHANNEL_HANDLERS].push({
122
213
  channelName: requestName,
123
214
  methodName: propertyKey,
124
- method: originalMethod
215
+ method: originalMethod,
216
+ options: options
125
217
  });
126
218
 
127
219
  return descriptor;
@@ -143,26 +235,82 @@ export function setupResponseHandlers(instance: any, element: HTMLElement) {
143
235
  const boundMethod = handler.method.bind(instance);
144
236
  const eventName = `@request/${handler.channelName}`;
145
237
 
238
+ // Create timing variables for debounce/throttle per handler
239
+ let debounceTimeout: any;
240
+ let throttleLastCall = 0;
241
+ let throttleTimeout: any;
242
+
243
+ // Create wrapped method with timing if needed
244
+ const createTimedMethod = (originalMethod: Function) => {
245
+ if (handler.options?.debounce) {
246
+ return (...args: any[]) => {
247
+ return new Promise((resolve, reject) => {
248
+ clearTimeout(debounceTimeout);
249
+ debounceTimeout = setTimeout(async () => {
250
+ try {
251
+ const result = await originalMethod(...args);
252
+ resolve(result);
253
+ } catch (error) {
254
+ reject(error);
255
+ }
256
+ }, handler.options.debounce);
257
+ });
258
+ };
259
+ }
260
+
261
+ if (handler.options?.throttle) {
262
+ return (...args: any[]) => {
263
+ const now = Date.now();
264
+ const remaining = handler.options.throttle! - (now - throttleLastCall);
265
+
266
+ if (remaining <= 0) {
267
+ clearTimeout(throttleTimeout);
268
+ throttleLastCall = now;
269
+ return originalMethod(...args);
270
+ } else if (!throttleTimeout) {
271
+ return new Promise((resolve, reject) => {
272
+ throttleTimeout = setTimeout(async () => {
273
+ throttleLastCall = Date.now();
274
+ throttleTimeout = null;
275
+ try {
276
+ const result = await originalMethod(...args);
277
+ resolve(result);
278
+ } catch (error) {
279
+ reject(error);
280
+ }
281
+ }, remaining);
282
+ });
283
+ }
284
+
285
+ // If throttled and timeout already exists, return cached/empty response
286
+ return Promise.resolve(undefined);
287
+ };
288
+ }
289
+
290
+ return originalMethod;
291
+ };
292
+
293
+ const timedMethod = createTimedMethod(boundMethod);
294
+
146
295
  // Setup response handler
147
296
  const responseHandler = (event: CustomEvent) => {
148
297
  // Extract promises and payload
149
- const { data, timeout, payload } = event.detail;
298
+ const { data, discovery, payload } = event.detail;
150
299
 
151
300
  // Prevent other responders from responding
152
301
  event.preventDefault();
153
302
  event.stopImmediatePropagation();
154
303
  event.stopPropagation();
155
304
 
156
- // Call the responder method and handle the result
157
- Promise.resolve(boundMethod(payload))
305
+ // Clear the discovery timeout immediately - we found a handler
306
+ discovery.resolve();
307
+
308
+ // Call the timed responder method and handle the result
309
+ Promise.resolve(timedMethod(payload))
158
310
  .then(result => {
159
- // Clear the timeout and resolve the data promise
160
- timeout.resolve();
161
311
  data.resolve(result);
162
312
  })
163
313
  .catch(error => {
164
- // Clear timeout and reject the data promise on error
165
- timeout.resolve();
166
314
  data.reject(error);
167
315
  console.error(`Error in response handler ${handler.methodName}:`, error);
168
316
  });
package/src/symbols.ts CHANGED
@@ -43,4 +43,8 @@ export const READY_HANDLERS = getSymbol('ready-handlers');
43
43
  export const DISPOSE_HANDLERS = getSymbol('dispose-handlers');
44
44
 
45
45
  // Observer symbols
46
- export const OBSERVERS = getSymbol('observers');
46
+ export const OBSERVERS = getSymbol('observers');
47
+
48
+ // Part symbols
49
+ export const PARTS = getSymbol('parts');
50
+ export const PART_TIMERS = getSymbol('part-timers');