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 +144 -5
- package/bin/templates/base/src/components/counter-button.ts +1 -1
- package/bin/templates/base/src/controllers/counter-controller.ts +1 -1
- package/package.json +36 -2
- package/src/controller.ts +2 -2
- package/src/element.ts +297 -20
- package/src/index.ts +4 -4
- package/src/request-response.ts +179 -31
- package/src/symbols.ts +5 -1
- /package/bin/templates/base/src/{types/counter-button.ts → components/counter-button.types.ts} +0 -0
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
|
-
- **`@
|
|
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
|
-
@
|
|
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
|
|
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 '
|
|
2
|
+
import type { ICounterButton } from './counter-button.types';
|
|
3
3
|
|
|
4
4
|
@element('counter-button')
|
|
5
5
|
export class CounterButton extends HTMLElement implements ICounterButton {
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "snice",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.1",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "
|
|
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
|
|
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
|
|
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
|
-
|
|
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 @
|
|
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 @
|
|
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
|
-
|
|
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
|
-
//
|
|
348
|
-
//
|
|
349
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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';
|
package/src/request-response.ts
CHANGED
|
@@ -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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
46
|
-
let
|
|
47
|
-
let
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
reject(new Error(`Request
|
|
53
|
-
},
|
|
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
|
-
|
|
90
|
+
discovery: {
|
|
65
91
|
resolve: () => {
|
|
66
|
-
clearTimeout(
|
|
67
|
-
|
|
92
|
+
clearTimeout(discoveryTimeoutId);
|
|
93
|
+
discoveryResolve();
|
|
68
94
|
},
|
|
69
|
-
reject:
|
|
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
|
|
85
|
-
await
|
|
86
|
-
|
|
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
|
|
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,
|
|
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
|
-
//
|
|
157
|
-
|
|
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');
|
/package/bin/templates/base/src/{types/counter-button.ts → components/counter-button.types.ts}
RENAMED
|
File without changes
|