dalila 1.9.18 → 1.9.19
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 +39 -289
- package/dist/core/scheduler.d.ts +19 -2
- package/dist/core/scheduler.js +137 -24
- package/dist/core/signal.js +1 -1
- package/dist/router/router.js +35 -24
- package/dist/runtime/bind.js +102 -94
- package/dist/runtime/lazy.d.ts +2 -1
- package/dist/runtime/lazy.js +39 -28
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -15,12 +15,14 @@ npm run dev
|
|
|
15
15
|
|
|
16
16
|
Open http://localhost:4242 to see your app.
|
|
17
17
|
|
|
18
|
-
##
|
|
18
|
+
## Install
|
|
19
19
|
|
|
20
20
|
```bash
|
|
21
21
|
npm install dalila
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
+
## Minimal Example
|
|
25
|
+
|
|
24
26
|
```html
|
|
25
27
|
<div id="app">
|
|
26
28
|
<p>Count: {count}</p>
|
|
@@ -44,313 +46,61 @@ bind(document.getElementById('app')!, ctx);
|
|
|
44
46
|
|
|
45
47
|
## Docs
|
|
46
48
|
|
|
47
|
-
###
|
|
49
|
+
### Start here
|
|
48
50
|
|
|
49
|
-
- [Overview](./docs/index.md)
|
|
50
|
-
- [Template Spec](./docs/template-spec.md)
|
|
51
|
+
- [Overview](./docs/index.md)
|
|
52
|
+
- [Template Spec](./docs/template-spec.md)
|
|
53
|
+
- [Router](./docs/router.md)
|
|
54
|
+
- [Forms](./docs/forms.md)
|
|
55
|
+
- [UI Components](./docs/ui.md)
|
|
56
|
+
- [HTTP Client](./docs/http.md)
|
|
51
57
|
|
|
52
58
|
### Core
|
|
53
59
|
|
|
54
|
-
- [Signals](./docs/core/signals.md)
|
|
55
|
-
- [Scopes](./docs/core/scope.md)
|
|
56
|
-
- [Persist](./docs/core/persist.md)
|
|
57
|
-
- [Context](./docs/context.md)
|
|
60
|
+
- [Signals](./docs/core/signals.md)
|
|
61
|
+
- [Scopes](./docs/core/scope.md)
|
|
62
|
+
- [Persist](./docs/core/persist.md)
|
|
63
|
+
- [Context](./docs/context.md)
|
|
64
|
+
- [Scheduler](./docs/core/scheduler.md)
|
|
65
|
+
- [Keys](./docs/core/key.md)
|
|
66
|
+
- [Dev Mode](./docs/core/dev.md)
|
|
58
67
|
|
|
59
68
|
### Runtime
|
|
60
69
|
|
|
61
|
-
- [Template Binding](./docs/runtime/bind.md)
|
|
62
|
-
- [Components](./docs/runtime/component.md)
|
|
63
|
-
- [Lazy Loading](./docs/runtime/lazy.md)
|
|
64
|
-
- [Error Boundary](./docs/runtime/boundary.md)
|
|
65
|
-
- [FOUC Prevention](./docs/runtime/fouc-prevention.md)
|
|
66
|
-
|
|
67
|
-
### Routing
|
|
68
|
-
|
|
69
|
-
- [Router](./docs/router.md) — Client-side routing with nested layouts, preloading, and file-based route generation
|
|
70
|
-
- [Template Check CLI](./docs/cli/check.md) — `dalila check` static analysis for template/context consistency
|
|
71
|
-
|
|
72
|
-
### UI Components
|
|
73
|
-
|
|
74
|
-
- [UI Components](./docs/ui.md) — Interactive components (Dialog, Drawer, Toast, Tabs, Calendar, etc.) with native HTML and full ARIA support
|
|
75
|
-
|
|
76
|
-
### Rendering
|
|
77
|
-
|
|
78
|
-
- [when](./docs/core/when.md) — Conditional visibility
|
|
79
|
-
- [match](./docs/core/match.md) — Switch-style rendering
|
|
80
|
-
- [for](./docs/core/for.md) — List rendering with keyed diffing
|
|
81
|
-
- [Virtual Lists](./docs/core/virtual.md) — Fixed and dynamic-height windowed rendering with infinite-scroll hooks
|
|
82
|
-
|
|
83
|
-
### Data
|
|
84
|
-
|
|
85
|
-
- [Resources](./docs/core/resource.md) — Async data with loading/error states
|
|
86
|
-
- [Query](./docs/core/query.md) — Cached queries
|
|
87
|
-
- [Mutations](./docs/core/mutation.md) — Write operations
|
|
88
|
-
|
|
89
|
-
### HTTP
|
|
90
|
-
|
|
91
|
-
- [HTTP Client](./docs/http.md) — Native fetch-based client with XSRF protection and interceptors
|
|
70
|
+
- [Template Binding](./docs/runtime/bind.md)
|
|
71
|
+
- [Components](./docs/runtime/component.md)
|
|
72
|
+
- [Lazy Loading](./docs/runtime/lazy.md)
|
|
73
|
+
- [Error Boundary](./docs/runtime/boundary.md)
|
|
74
|
+
- [FOUC Prevention](./docs/runtime/fouc-prevention.md)
|
|
92
75
|
|
|
93
|
-
###
|
|
76
|
+
### Rendering & Data
|
|
94
77
|
|
|
95
|
-
- [
|
|
78
|
+
- [when](./docs/core/when.md)
|
|
79
|
+
- [match](./docs/core/match.md)
|
|
80
|
+
- [for](./docs/core/for.md)
|
|
81
|
+
- [Virtual Lists](./docs/core/virtual.md)
|
|
82
|
+
- [Resources](./docs/core/resource.md)
|
|
83
|
+
- [Query](./docs/core/query.md)
|
|
84
|
+
- [Mutations](./docs/core/mutation.md)
|
|
96
85
|
|
|
97
|
-
###
|
|
86
|
+
### Tooling
|
|
98
87
|
|
|
99
|
-
- [
|
|
100
|
-
- [
|
|
101
|
-
- [Dev Mode](./docs/core/dev.md) — Warnings and helpers
|
|
102
|
-
- [Devtools Extension](./devtools-extension/README.md) — Browser panel for reactive graph and scopes
|
|
88
|
+
- [Template Check CLI](./docs/cli/check.md)
|
|
89
|
+
- [Devtools Extension](./devtools-extension/README.md)
|
|
103
90
|
|
|
104
91
|
Firefox extension workflows:
|
|
105
92
|
|
|
106
93
|
- `npm run devtools:firefox:run` — launch Firefox with extension loaded for dev
|
|
107
94
|
- `npm run devtools:firefox:build` — package extension artifact for submission/signing
|
|
108
95
|
|
|
109
|
-
##
|
|
110
|
-
|
|
111
|
-
```
|
|
112
|
-
dalila → signal, computed, effect, batch, ...
|
|
113
|
-
dalila/runtime → bind(), mount(), configure(), createPortalTarget(), defineComponent()
|
|
114
|
-
dalila/context → createContext, provide, inject
|
|
115
|
-
dalila/http → createHttpClient with XSRF protection
|
|
116
|
-
```
|
|
117
|
-
|
|
118
|
-
### Signals
|
|
119
|
-
|
|
120
|
-
```ts
|
|
121
|
-
import { signal, computed, effect, readonly, debounceSignal, throttleSignal } from 'dalila';
|
|
122
|
-
|
|
123
|
-
const count = signal(0);
|
|
124
|
-
const doubled = computed(() => count() * 2);
|
|
125
|
-
const countRO = readonly(count);
|
|
126
|
-
const search = signal('');
|
|
127
|
-
const debouncedSearch = debounceSignal(search, 250);
|
|
128
|
-
const scrollY = signal(0);
|
|
129
|
-
const throttledScrollY = throttleSignal(scrollY, 16);
|
|
130
|
-
|
|
131
|
-
effect(() => {
|
|
132
|
-
console.log('Count is', countRO());
|
|
133
|
-
console.log('Search after pause', debouncedSearch());
|
|
134
|
-
console.log('Scroll sample', throttledScrollY());
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
count.set(5); // logs: Count is 5
|
|
138
|
-
```
|
|
139
|
-
|
|
140
|
-
### Template Binding
|
|
141
|
-
|
|
142
|
-
```ts
|
|
143
|
-
import { configure, mount } from 'dalila/runtime';
|
|
144
|
-
|
|
145
|
-
// Global component registry and options
|
|
146
|
-
configure({ components: [MyComponent] });
|
|
147
|
-
|
|
148
|
-
// Bind a selector to a reactive view-model
|
|
149
|
-
const dispose = mount('.app', { count: signal(0) });
|
|
150
|
-
|
|
151
|
-
// Cleanup when done
|
|
152
|
-
dispose();
|
|
153
|
-
```
|
|
154
|
-
|
|
155
|
-
### Transitions and Portal
|
|
156
|
-
|
|
157
|
-
```html
|
|
158
|
-
<div d-when="open" d-transition="fade">Panel</div>
|
|
159
|
-
<div d-portal="showModal ? '#modal-root' : null">Modal content</div>
|
|
160
|
-
```
|
|
161
|
-
|
|
162
|
-
```ts
|
|
163
|
-
import { configure, createPortalTarget } from 'dalila/runtime';
|
|
164
|
-
|
|
165
|
-
const modalTarget = createPortalTarget('modal-root');
|
|
166
|
-
|
|
167
|
-
configure({
|
|
168
|
-
transitions: [{ name: 'fade', duration: 250 }],
|
|
169
|
-
});
|
|
170
|
-
```
|
|
171
|
-
|
|
172
|
-
### Scopes
|
|
173
|
-
|
|
174
|
-
```ts
|
|
175
|
-
import { createScope, withScope, effect } from 'dalila';
|
|
176
|
-
|
|
177
|
-
const scope = createScope();
|
|
178
|
-
|
|
179
|
-
withScope(scope, () => {
|
|
180
|
-
effect(() => { /* auto-cleaned when scope disposes */ });
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
scope.dispose(); // stops all effects
|
|
184
|
-
```
|
|
185
|
-
|
|
186
|
-
### Context
|
|
187
|
-
|
|
188
|
-
```ts
|
|
189
|
-
import { createContext, provide, inject } from 'dalila';
|
|
190
|
-
|
|
191
|
-
const ThemeContext = createContext<'light' | 'dark'>('theme');
|
|
192
|
-
|
|
193
|
-
// In parent scope
|
|
194
|
-
provide(ThemeContext, 'dark');
|
|
195
|
-
|
|
196
|
-
// In child scope
|
|
197
|
-
const theme = inject(ThemeContext); // 'dark'
|
|
198
|
-
```
|
|
199
|
-
|
|
200
|
-
### Persist
|
|
201
|
-
|
|
202
|
-
```ts
|
|
203
|
-
import { signal, persist } from 'dalila';
|
|
204
|
-
|
|
205
|
-
// Auto-saves to localStorage
|
|
206
|
-
const theme = persist(signal('dark'), { name: 'app-theme' });
|
|
207
|
-
|
|
208
|
-
theme.set('light'); // Saved automatically
|
|
209
|
-
// On reload: theme starts as 'light'
|
|
210
|
-
```
|
|
211
|
-
|
|
212
|
-
### HTTP Client
|
|
213
|
-
|
|
214
|
-
```ts
|
|
215
|
-
import { createHttpClient } from 'dalila/http';
|
|
216
|
-
|
|
217
|
-
const http = createHttpClient({
|
|
218
|
-
baseURL: 'https://api.example.com',
|
|
219
|
-
xsrf: true, // XSRF protection
|
|
220
|
-
onError: (error) => {
|
|
221
|
-
if (error.status === 401) window.location.href = '/login';
|
|
222
|
-
throw error;
|
|
223
|
-
}
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
// GET request
|
|
227
|
-
const response = await http.get('/users');
|
|
228
|
-
console.log(response.data);
|
|
229
|
-
|
|
230
|
-
// POST with auto JSON serialization
|
|
231
|
-
await http.post('/users', { name: 'John', email: 'john@example.com' });
|
|
232
|
-
```
|
|
233
|
-
|
|
234
|
-
### File-Based Routing
|
|
96
|
+
## Packages
|
|
235
97
|
|
|
236
98
|
```txt
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
└── users/
|
|
243
|
-
└── [id]/
|
|
244
|
-
└── page.html
|
|
245
|
-
```
|
|
246
|
-
|
|
247
|
-
```bash
|
|
248
|
-
npx dalila routes generate
|
|
249
|
-
```
|
|
250
|
-
|
|
251
|
-
```ts
|
|
252
|
-
import { createRouter } from 'dalila/router';
|
|
253
|
-
import { routes } from './routes.generated.js';
|
|
254
|
-
import { routeManifest } from './routes.generated.manifest.js';
|
|
255
|
-
|
|
256
|
-
const router = createRouter({
|
|
257
|
-
outlet: document.getElementById('app')!,
|
|
258
|
-
routes,
|
|
259
|
-
routeManifest
|
|
260
|
-
});
|
|
261
|
-
|
|
262
|
-
router.start();
|
|
263
|
-
```
|
|
264
|
-
|
|
265
|
-
### Forms
|
|
266
|
-
|
|
267
|
-
```ts
|
|
268
|
-
import { createForm } from 'dalila';
|
|
269
|
-
|
|
270
|
-
const userForm = createForm({
|
|
271
|
-
defaultValues: { name: '', email: '' },
|
|
272
|
-
validate: (data) => {
|
|
273
|
-
const errors: Record<string, string> = {};
|
|
274
|
-
if (!data.name) errors.name = 'Name is required';
|
|
275
|
-
if (!data.email?.includes('@')) errors.email = 'Invalid email';
|
|
276
|
-
return errors;
|
|
277
|
-
}
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
async function handleSubmit(data, { signal }) {
|
|
281
|
-
await fetch('/api/users', {
|
|
282
|
-
method: 'POST',
|
|
283
|
-
body: JSON.stringify(data),
|
|
284
|
-
signal
|
|
285
|
-
});
|
|
286
|
-
}
|
|
287
|
-
```
|
|
288
|
-
|
|
289
|
-
```html
|
|
290
|
-
<form d-form="userForm" d-on-submit="handleSubmit">
|
|
291
|
-
<label>
|
|
292
|
-
Name
|
|
293
|
-
<input d-field="name" />
|
|
294
|
-
</label>
|
|
295
|
-
<span d-error="name"></span>
|
|
296
|
-
|
|
297
|
-
<label>
|
|
298
|
-
Email
|
|
299
|
-
<input d-field="email" type="email" />
|
|
300
|
-
</label>
|
|
301
|
-
<span d-error="email"></span>
|
|
302
|
-
|
|
303
|
-
<button type="submit">Save</button>
|
|
304
|
-
<span d-form-error="userForm"></span>
|
|
305
|
-
</form>
|
|
306
|
-
```
|
|
307
|
-
|
|
308
|
-
### UI Components with Router
|
|
309
|
-
|
|
310
|
-
```ts
|
|
311
|
-
// page.ts
|
|
312
|
-
import { computed, signal } from 'dalila';
|
|
313
|
-
import { createDialog, mountUI } from 'dalila/components/ui';
|
|
314
|
-
|
|
315
|
-
class HomePageVM {
|
|
316
|
-
count = signal(0);
|
|
317
|
-
status = computed(() => `Count: ${this.count()}`);
|
|
318
|
-
dialog = createDialog({ closeOnBackdrop: true, closeOnEscape: true });
|
|
319
|
-
increment = () => this.count.update(n => n + 1);
|
|
320
|
-
openModal = () => this.dialog.show();
|
|
321
|
-
closeModal = () => this.dialog.close();
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
export function loader() {
|
|
325
|
-
return new HomePageVM();
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
// Called after view is mounted
|
|
329
|
-
export function onMount(root: HTMLElement, data: HomePageVM) {
|
|
330
|
-
return mountUI(root, {
|
|
331
|
-
dialogs: { dialog: data.dialog },
|
|
332
|
-
events: []
|
|
333
|
-
});
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
// Optional extra hook on route leave
|
|
337
|
-
export function onUnmount(_root: HTMLElement) {}
|
|
338
|
-
```
|
|
339
|
-
|
|
340
|
-
```html
|
|
341
|
-
<!-- page.html -->
|
|
342
|
-
<d-button d-on-click="increment">Increment</d-button>
|
|
343
|
-
<d-button d-on-click="openModal">Open Dialog</d-button>
|
|
344
|
-
|
|
345
|
-
<d-dialog d-ui="dialog">
|
|
346
|
-
<d-dialog-header>
|
|
347
|
-
<d-dialog-title>{status}</d-dialog-title>
|
|
348
|
-
<d-dialog-close d-on-click="closeModal">×</d-dialog-close>
|
|
349
|
-
</d-dialog-header>
|
|
350
|
-
<d-dialog-body>
|
|
351
|
-
<p>Modal controlled by the official route + UI pattern.</p>
|
|
352
|
-
</d-dialog-body>
|
|
353
|
-
</d-dialog>
|
|
99
|
+
dalila → signals, scope, persist, forms, resources, query, mutations
|
|
100
|
+
dalila/runtime → bind(), mount(), configure(), components, lazy, transitions
|
|
101
|
+
dalila/context → createContext(), provide(), inject()
|
|
102
|
+
dalila/router → createRouter(), file-based routes, preloading
|
|
103
|
+
dalila/http → createHttpClient()
|
|
354
104
|
```
|
|
355
105
|
|
|
356
106
|
## Development
|
package/dist/core/scheduler.d.ts
CHANGED
|
@@ -20,6 +20,15 @@
|
|
|
20
20
|
* - Flushes always drain with `splice(0)` to release references eagerly.
|
|
21
21
|
*/
|
|
22
22
|
type Task = () => void;
|
|
23
|
+
export type SchedulerPriority = 'high' | 'medium' | 'low';
|
|
24
|
+
export interface ScheduleOptions {
|
|
25
|
+
/** Priority used by RAF/microtask queues. Default: 'medium'. */
|
|
26
|
+
priority?: SchedulerPriority;
|
|
27
|
+
}
|
|
28
|
+
interface SchedulerPriorityContextOptions {
|
|
29
|
+
/** Warn when `fn` returns a promise. Default: true. */
|
|
30
|
+
warnOnAsync?: boolean;
|
|
31
|
+
}
|
|
23
32
|
export interface TimeSliceOptions {
|
|
24
33
|
/** Time budget per slice in milliseconds. Default: 8 */
|
|
25
34
|
budgetMs?: number;
|
|
@@ -71,7 +80,7 @@ export declare function getSchedulerConfig(): Readonly<SchedulerConfig>;
|
|
|
71
80
|
* Use this for DOM-affecting work you want grouped per frame.
|
|
72
81
|
* (In Node tests, `requestAnimationFrame` is typically mocked.)
|
|
73
82
|
*/
|
|
74
|
-
export declare function schedule(task: Task): void;
|
|
83
|
+
export declare function schedule(task: Task, options?: ScheduleOptions): void;
|
|
75
84
|
/**
|
|
76
85
|
* Schedule work in a microtask.
|
|
77
86
|
*
|
|
@@ -79,7 +88,7 @@ export declare function schedule(task: Task): void;
|
|
|
79
88
|
* - run after the current call stack,
|
|
80
89
|
* - but before the next frame.
|
|
81
90
|
*/
|
|
82
|
-
export declare function scheduleMicrotask(task: Task): void;
|
|
91
|
+
export declare function scheduleMicrotask(task: Task, options?: ScheduleOptions): void;
|
|
83
92
|
/**
|
|
84
93
|
* Returns true while inside a `batch()` call.
|
|
85
94
|
*
|
|
@@ -108,6 +117,14 @@ export declare function queueInBatch(task: Task): void;
|
|
|
108
117
|
* - We flush batched tasks in *one RAF* to group visible DOM work per frame.
|
|
109
118
|
*/
|
|
110
119
|
export declare function batch(fn: () => void): void;
|
|
120
|
+
/**
|
|
121
|
+
* Run work under a temporary scheduler priority context.
|
|
122
|
+
*
|
|
123
|
+
* Sync-only helper: tasks scheduled without explicit priority inside the
|
|
124
|
+
* synchronous body of `fn` inherit this priority.
|
|
125
|
+
* Useful for framework internals (e.g. user input event handlers).
|
|
126
|
+
*/
|
|
127
|
+
export declare function withSchedulerPriority<T>(priority: SchedulerPriority, fn: () => T, options?: SchedulerPriorityContextOptions): T;
|
|
111
128
|
/**
|
|
112
129
|
* DOM read discipline helper.
|
|
113
130
|
*
|
package/dist/core/scheduler.js
CHANGED
|
@@ -23,6 +23,13 @@ const schedulerConfig = {
|
|
|
23
23
|
maxMicrotaskIterations: 1000,
|
|
24
24
|
maxRafIterations: 100,
|
|
25
25
|
};
|
|
26
|
+
const PRIORITY_ORDER = ['high', 'medium', 'low'];
|
|
27
|
+
const FAIRNESS_QUOTAS = {
|
|
28
|
+
high: 8,
|
|
29
|
+
medium: 4,
|
|
30
|
+
low: 2,
|
|
31
|
+
};
|
|
32
|
+
const VALID_SCHEDULER_PRIORITIES = new Set(PRIORITY_ORDER);
|
|
26
33
|
/**
|
|
27
34
|
* Configure scheduler limits.
|
|
28
35
|
*
|
|
@@ -44,11 +51,22 @@ export function getSchedulerConfig() {
|
|
|
44
51
|
}
|
|
45
52
|
let rafScheduled = false;
|
|
46
53
|
let microtaskScheduled = false;
|
|
54
|
+
let currentPriorityContext = null;
|
|
55
|
+
const warnedInvalidPriorityValues = new Set();
|
|
56
|
+
let warnedAsyncPriorityContextUsage = false;
|
|
47
57
|
let isFlushingRaf = false;
|
|
48
58
|
let isFlushingMicrotasks = false;
|
|
49
|
-
/** FIFO queues. */
|
|
50
|
-
const rafQueue =
|
|
51
|
-
|
|
59
|
+
/** FIFO queues split by priority. */
|
|
60
|
+
const rafQueue = {
|
|
61
|
+
high: [],
|
|
62
|
+
medium: [],
|
|
63
|
+
low: [],
|
|
64
|
+
};
|
|
65
|
+
const microtaskQueue = {
|
|
66
|
+
high: [],
|
|
67
|
+
medium: [],
|
|
68
|
+
low: [],
|
|
69
|
+
};
|
|
52
70
|
const rafImpl = typeof globalThis !== 'undefined' && typeof globalThis.requestAnimationFrame === 'function'
|
|
53
71
|
? (cb) => globalThis.requestAnimationFrame(() => cb())
|
|
54
72
|
: (cb) => setTimeout(cb, 0);
|
|
@@ -64,6 +82,21 @@ function assertNotAborted(signal) {
|
|
|
64
82
|
if (signal?.aborted)
|
|
65
83
|
throw createAbortError();
|
|
66
84
|
}
|
|
85
|
+
function isSchedulerPriority(value) {
|
|
86
|
+
return typeof value === 'string' && VALID_SCHEDULER_PRIORITIES.has(value);
|
|
87
|
+
}
|
|
88
|
+
function warnInvalidSchedulerPriority(value, source) {
|
|
89
|
+
const key = `${source}:${String(value)}`;
|
|
90
|
+
if (warnedInvalidPriorityValues.has(key))
|
|
91
|
+
return;
|
|
92
|
+
warnedInvalidPriorityValues.add(key);
|
|
93
|
+
console.warn(`[Dalila] Invalid scheduler priority from ${source}: "${String(value)}". Falling back to "medium".`);
|
|
94
|
+
}
|
|
95
|
+
function isPromiseLike(value) {
|
|
96
|
+
return (typeof value === 'object' &&
|
|
97
|
+
value !== null &&
|
|
98
|
+
typeof value.then === 'function');
|
|
99
|
+
}
|
|
67
100
|
/**
|
|
68
101
|
* Batching state.
|
|
69
102
|
*
|
|
@@ -80,8 +113,8 @@ const batchQueueSet = new Set();
|
|
|
80
113
|
* Use this for DOM-affecting work you want grouped per frame.
|
|
81
114
|
* (In Node tests, `requestAnimationFrame` is typically mocked.)
|
|
82
115
|
*/
|
|
83
|
-
export function schedule(task) {
|
|
84
|
-
rafQueue.push(task);
|
|
116
|
+
export function schedule(task, options = {}) {
|
|
117
|
+
rafQueue[normalizePriority(options.priority)].push(task);
|
|
85
118
|
if (!rafScheduled) {
|
|
86
119
|
rafScheduled = true;
|
|
87
120
|
rafImpl(flushRaf);
|
|
@@ -94,8 +127,8 @@ export function schedule(task) {
|
|
|
94
127
|
* - run after the current call stack,
|
|
95
128
|
* - but before the next frame.
|
|
96
129
|
*/
|
|
97
|
-
export function scheduleMicrotask(task) {
|
|
98
|
-
microtaskQueue.push(task);
|
|
130
|
+
export function scheduleMicrotask(task, options = {}) {
|
|
131
|
+
microtaskQueue[normalizePriority(options.priority)].push(task);
|
|
99
132
|
if (!microtaskScheduled) {
|
|
100
133
|
microtaskScheduled = true;
|
|
101
134
|
Promise.resolve().then(flushMicrotasks);
|
|
@@ -160,7 +193,7 @@ function flushBatch() {
|
|
|
160
193
|
schedule(() => {
|
|
161
194
|
for (const t of tasks)
|
|
162
195
|
t();
|
|
163
|
-
});
|
|
196
|
+
}, { priority: 'medium' });
|
|
164
197
|
}
|
|
165
198
|
/**
|
|
166
199
|
* Drain the microtask queue.
|
|
@@ -177,23 +210,21 @@ function flushMicrotasks() {
|
|
|
177
210
|
let iterations = 0;
|
|
178
211
|
const maxIterations = schedulerConfig.maxMicrotaskIterations;
|
|
179
212
|
try {
|
|
180
|
-
while (microtaskQueue
|
|
213
|
+
while (hasQueuedTasks(microtaskQueue) && iterations < maxIterations) {
|
|
181
214
|
iterations++;
|
|
182
|
-
|
|
183
|
-
for (const t of tasks)
|
|
184
|
-
t();
|
|
215
|
+
drainPriorityCycle(microtaskQueue);
|
|
185
216
|
}
|
|
186
|
-
if (iterations >= maxIterations && microtaskQueue
|
|
217
|
+
if (iterations >= maxIterations && hasQueuedTasks(microtaskQueue)) {
|
|
187
218
|
console.error(`[Dalila] Scheduler exceeded ${maxIterations} microtask iterations. ` +
|
|
188
|
-
`Possible infinite loop detected. Remaining ${microtaskQueue
|
|
189
|
-
microtaskQueue
|
|
219
|
+
`Possible infinite loop detected. Remaining ${countQueuedTasks(microtaskQueue)} tasks discarded.`);
|
|
220
|
+
clearPriorityQueues(microtaskQueue);
|
|
190
221
|
}
|
|
191
222
|
}
|
|
192
223
|
finally {
|
|
193
224
|
isFlushingMicrotasks = false;
|
|
194
225
|
microtaskScheduled = false;
|
|
195
226
|
// If tasks were queued after we stopped flushing, reschedule a new microtask turn.
|
|
196
|
-
if (microtaskQueue
|
|
227
|
+
if (hasQueuedTasks(microtaskQueue) && !microtaskScheduled) {
|
|
197
228
|
microtaskScheduled = true;
|
|
198
229
|
Promise.resolve().then(flushMicrotasks);
|
|
199
230
|
}
|
|
@@ -213,28 +244,110 @@ function flushRaf() {
|
|
|
213
244
|
let iterations = 0;
|
|
214
245
|
const maxIterations = schedulerConfig.maxRafIterations;
|
|
215
246
|
try {
|
|
216
|
-
while (rafQueue
|
|
247
|
+
while (hasQueuedTasks(rafQueue) && iterations < maxIterations) {
|
|
217
248
|
iterations++;
|
|
218
|
-
|
|
219
|
-
for (const t of tasks)
|
|
220
|
-
t();
|
|
249
|
+
drainPriorityCycle(rafQueue);
|
|
221
250
|
}
|
|
222
|
-
if (iterations >= maxIterations && rafQueue
|
|
251
|
+
if (iterations >= maxIterations && hasQueuedTasks(rafQueue)) {
|
|
223
252
|
console.error(`[Dalila] Scheduler exceeded ${maxIterations} RAF iterations. ` +
|
|
224
|
-
`Possible infinite loop detected. Remaining ${rafQueue
|
|
225
|
-
rafQueue
|
|
253
|
+
`Possible infinite loop detected. Remaining ${countQueuedTasks(rafQueue)} tasks discarded.`);
|
|
254
|
+
clearPriorityQueues(rafQueue);
|
|
226
255
|
}
|
|
227
256
|
}
|
|
228
257
|
finally {
|
|
229
258
|
isFlushingRaf = false;
|
|
230
259
|
rafScheduled = false;
|
|
231
260
|
// If tasks were queued during the flush, schedule another frame.
|
|
232
|
-
if (rafQueue
|
|
261
|
+
if (hasQueuedTasks(rafQueue) && !rafScheduled) {
|
|
233
262
|
rafScheduled = true;
|
|
234
263
|
rafImpl(flushRaf);
|
|
235
264
|
}
|
|
236
265
|
}
|
|
237
266
|
}
|
|
267
|
+
function normalizePriority(priority) {
|
|
268
|
+
const candidate = priority ?? currentPriorityContext;
|
|
269
|
+
if (candidate == null)
|
|
270
|
+
return 'medium';
|
|
271
|
+
if (isSchedulerPriority(candidate))
|
|
272
|
+
return candidate;
|
|
273
|
+
warnInvalidSchedulerPriority(candidate, priority != null ? 'schedule-options' : 'priority-context');
|
|
274
|
+
return 'medium';
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Run work under a temporary scheduler priority context.
|
|
278
|
+
*
|
|
279
|
+
* Sync-only helper: tasks scheduled without explicit priority inside the
|
|
280
|
+
* synchronous body of `fn` inherit this priority.
|
|
281
|
+
* Useful for framework internals (e.g. user input event handlers).
|
|
282
|
+
*/
|
|
283
|
+
export function withSchedulerPriority(priority, fn, options = {}) {
|
|
284
|
+
const nextPriority = isSchedulerPriority(priority) ? priority : (warnInvalidSchedulerPriority(priority, 'withSchedulerPriority'), 'medium');
|
|
285
|
+
const prev = currentPriorityContext;
|
|
286
|
+
currentPriorityContext = nextPriority;
|
|
287
|
+
try {
|
|
288
|
+
const result = fn();
|
|
289
|
+
if ((options.warnOnAsync ?? true) && isPromiseLike(result) && !warnedAsyncPriorityContextUsage) {
|
|
290
|
+
warnedAsyncPriorityContextUsage = true;
|
|
291
|
+
console.warn('[Dalila] withSchedulerPriority() is sync-only and does not preserve priority across async boundaries.');
|
|
292
|
+
}
|
|
293
|
+
return result;
|
|
294
|
+
}
|
|
295
|
+
finally {
|
|
296
|
+
currentPriorityContext = prev;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
function hasQueuedTasks(queues) {
|
|
300
|
+
return queues.high.length > 0 || queues.medium.length > 0 || queues.low.length > 0;
|
|
301
|
+
}
|
|
302
|
+
function hasPendingPriorityCounts(counts) {
|
|
303
|
+
return counts.high > 0 || counts.medium > 0 || counts.low > 0;
|
|
304
|
+
}
|
|
305
|
+
function countQueuedTasks(queues) {
|
|
306
|
+
return queues.high.length + queues.medium.length + queues.low.length;
|
|
307
|
+
}
|
|
308
|
+
function clearPriorityQueues(queues) {
|
|
309
|
+
queues.high.length = 0;
|
|
310
|
+
queues.medium.length = 0;
|
|
311
|
+
queues.low.length = 0;
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Drain one fairness wave from the current queue snapshot:
|
|
315
|
+
* - prioritize `high`
|
|
316
|
+
* - still guarantee progress for `medium`/`low` if queued
|
|
317
|
+
*
|
|
318
|
+
* Tasks enqueued while draining are deferred to the next outer iteration so
|
|
319
|
+
* loop-protection still counts requeue waves instead of fairness slices.
|
|
320
|
+
*/
|
|
321
|
+
function drainPriorityCycle(queues) {
|
|
322
|
+
const snapshot = {
|
|
323
|
+
high: queues.high.splice(0),
|
|
324
|
+
medium: queues.medium.splice(0),
|
|
325
|
+
low: queues.low.splice(0)
|
|
326
|
+
};
|
|
327
|
+
const indices = {
|
|
328
|
+
high: 0,
|
|
329
|
+
medium: 0,
|
|
330
|
+
low: 0
|
|
331
|
+
};
|
|
332
|
+
const remaining = {
|
|
333
|
+
high: snapshot.high.length,
|
|
334
|
+
medium: snapshot.medium.length,
|
|
335
|
+
low: snapshot.low.length
|
|
336
|
+
};
|
|
337
|
+
while (hasPendingPriorityCounts(remaining)) {
|
|
338
|
+
for (const priority of PRIORITY_ORDER) {
|
|
339
|
+
if (remaining[priority] === 0)
|
|
340
|
+
continue;
|
|
341
|
+
const start = indices[priority];
|
|
342
|
+
const end = Math.min(start + FAIRNESS_QUOTAS[priority], snapshot[priority].length);
|
|
343
|
+
indices[priority] = end;
|
|
344
|
+
remaining[priority] -= end - start;
|
|
345
|
+
for (let i = start; i < end; i++) {
|
|
346
|
+
snapshot[priority][i]();
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
238
351
|
/**
|
|
239
352
|
* DOM read discipline helper.
|
|
240
353
|
*
|
package/dist/core/signal.js
CHANGED
|
@@ -84,7 +84,7 @@ function scheduleEffect(eff) {
|
|
|
84
84
|
if (isBatching())
|
|
85
85
|
queueInBatch(eff.runner);
|
|
86
86
|
else
|
|
87
|
-
scheduleMicrotask(eff.runner);
|
|
87
|
+
scheduleMicrotask(eff.runner, { priority: 'medium' });
|
|
88
88
|
}
|
|
89
89
|
function trySubscribeActiveEffect(subscribers, signalRef) {
|
|
90
90
|
if (!activeEffect || activeEffect.disposed)
|
package/dist/router/router.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { signal } from '../core/signal.js';
|
|
2
|
+
import { withSchedulerPriority } from '../core/scheduler.js';
|
|
2
3
|
import { createScope, withScope, withScopeAsync } from '../core/scope.js';
|
|
3
4
|
import { normalizeRules, validateValue } from './validation.js';
|
|
4
5
|
import { compileRoutes, findCompiledRouteStackResult, normalizePath } from './route-tables.js';
|
|
@@ -568,7 +569,7 @@ export function createRouter(config) {
|
|
|
568
569
|
}
|
|
569
570
|
return collectPrefetchCandidatesFromRoutes(routes);
|
|
570
571
|
}
|
|
571
|
-
async function prefetchCandidates(candidates) {
|
|
572
|
+
async function prefetchCandidates(candidates, priority = 'medium') {
|
|
572
573
|
if (candidates.length === 0)
|
|
573
574
|
return;
|
|
574
575
|
const seenStaticPaths = new Set();
|
|
@@ -580,7 +581,7 @@ export function createRouter(config) {
|
|
|
580
581
|
if (seenStaticPaths.has(staticPath))
|
|
581
582
|
continue;
|
|
582
583
|
seenStaticPaths.add(staticPath);
|
|
583
|
-
tasks.push(preloadPath(staticPath).catch((error) => {
|
|
584
|
+
tasks.push(preloadPath(staticPath, priority).catch((error) => {
|
|
584
585
|
console.warn('[Dalila] Route prefetch failed:', error);
|
|
585
586
|
}));
|
|
586
587
|
continue;
|
|
@@ -1402,7 +1403,7 @@ export function createRouter(config) {
|
|
|
1402
1403
|
* functions. Awaits all pending entry promises before returning so that
|
|
1403
1404
|
* callers like prefetchByTag can trust the data is ready.
|
|
1404
1405
|
*/
|
|
1405
|
-
async function preloadPath(path) {
|
|
1406
|
+
async function preloadPath(path, priority = 'medium') {
|
|
1406
1407
|
const location = parseLocation(path);
|
|
1407
1408
|
const stackResult = findCompiledRouteStackResult(location.pathname, compiledRoutes);
|
|
1408
1409
|
if (!stackResult || !stackResult.exact)
|
|
@@ -1461,18 +1462,22 @@ export function createRouter(config) {
|
|
|
1461
1462
|
}
|
|
1462
1463
|
const settled = entry.promise
|
|
1463
1464
|
.then((data) => {
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1465
|
+
withSchedulerPriority(priority, () => {
|
|
1466
|
+
if (!controller.signal.aborted) {
|
|
1467
|
+
entry.status = 'fulfilled';
|
|
1468
|
+
entry.data = data;
|
|
1469
|
+
}
|
|
1470
|
+
preloadScope.dispose();
|
|
1471
|
+
});
|
|
1469
1472
|
})
|
|
1470
1473
|
.catch((error) => {
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1474
|
+
withSchedulerPriority(priority, () => {
|
|
1475
|
+
if (!isAbortError(error)) {
|
|
1476
|
+
entry.status = 'rejected';
|
|
1477
|
+
entry.error = error;
|
|
1478
|
+
}
|
|
1479
|
+
preloadScope.dispose();
|
|
1480
|
+
});
|
|
1476
1481
|
});
|
|
1477
1482
|
pending.push(settled);
|
|
1478
1483
|
}
|
|
@@ -1481,27 +1486,33 @@ export function createRouter(config) {
|
|
|
1481
1486
|
}
|
|
1482
1487
|
}
|
|
1483
1488
|
function preload(path) {
|
|
1484
|
-
void preloadPath(path).catch((error) => {
|
|
1489
|
+
void preloadPath(path, 'low').catch((error) => {
|
|
1485
1490
|
console.warn('[Dalila] Route prefetch failed:', error);
|
|
1486
1491
|
});
|
|
1487
1492
|
}
|
|
1488
1493
|
async function prefetchByTag(tag) {
|
|
1489
|
-
const
|
|
1490
|
-
|
|
1494
|
+
const candidates = withSchedulerPriority('low', () => {
|
|
1495
|
+
const normalizedTag = tag.trim();
|
|
1496
|
+
if (!normalizedTag)
|
|
1497
|
+
return null;
|
|
1498
|
+
return collectPrefetchCandidates().filter((candidate) => candidate.tags.includes(normalizedTag));
|
|
1499
|
+
});
|
|
1500
|
+
if (!candidates)
|
|
1491
1501
|
return;
|
|
1492
|
-
|
|
1493
|
-
await prefetchCandidates(candidates);
|
|
1502
|
+
await prefetchCandidates(candidates, 'low');
|
|
1494
1503
|
}
|
|
1495
1504
|
async function prefetchByScore(minScore) {
|
|
1496
1505
|
if (!Number.isFinite(minScore))
|
|
1497
1506
|
return;
|
|
1498
|
-
const
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1507
|
+
const candidates = withSchedulerPriority('low', () => {
|
|
1508
|
+
const threshold = Number(minScore);
|
|
1509
|
+
return collectPrefetchCandidates().filter((candidate) => {
|
|
1510
|
+
if (typeof candidate.score !== 'number')
|
|
1511
|
+
return false;
|
|
1512
|
+
return candidate.score >= threshold;
|
|
1513
|
+
});
|
|
1503
1514
|
});
|
|
1504
|
-
await prefetchCandidates(candidates);
|
|
1515
|
+
await prefetchCandidates(candidates, 'low');
|
|
1505
1516
|
}
|
|
1506
1517
|
function start() {
|
|
1507
1518
|
if (started)
|
package/dist/runtime/bind.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* @module dalila/runtime
|
|
8
8
|
*/
|
|
9
9
|
import { effect, createScope, withScope, isInDevMode, signal, computeVirtualRange } from '../core/index.js';
|
|
10
|
+
import { schedule, withSchedulerPriority } from '../core/scheduler.js';
|
|
10
11
|
import { WRAPPED_HANDLER } from '../form/form.js';
|
|
11
12
|
import { linkScopeToDom, withDevtoolsDomTarget } from '../core/devtools.js';
|
|
12
13
|
import { isComponent, normalizePropDef, coercePropValue, kebabToCamel, camelToKebab } from './component.js';
|
|
@@ -244,13 +245,14 @@ const templateInterpolationPlanCache = new Map();
|
|
|
244
245
|
const TEMPLATE_PLAN_CACHE_MAX_ENTRIES = 250;
|
|
245
246
|
const TEMPLATE_PLAN_CACHE_TTL_MS = 10 * 60 * 1000;
|
|
246
247
|
const TEMPLATE_PLAN_CACHE_CONFIG_KEY = '__dalila_bind_template_cache';
|
|
247
|
-
const BENCH_FLAG = '__dalila_bind_bench';
|
|
248
|
-
const BENCH_STATS_KEY = '__dalila_bind_bench_stats';
|
|
249
248
|
function nowMs() {
|
|
250
249
|
return typeof performance !== 'undefined' && typeof performance.now === 'function'
|
|
251
250
|
? performance.now()
|
|
252
251
|
: Date.now();
|
|
253
252
|
}
|
|
253
|
+
function resolveListRenderPriority() {
|
|
254
|
+
return 'low';
|
|
255
|
+
}
|
|
254
256
|
function coerceCacheSetting(value, fallback) {
|
|
255
257
|
if (typeof value !== 'number' || !Number.isFinite(value))
|
|
256
258
|
return fallback;
|
|
@@ -263,40 +265,6 @@ function resolveTemplatePlanCacheConfig(options) {
|
|
|
263
265
|
const ttlMs = coerceCacheSetting(fromOptions?.ttlMs ?? globalRaw?.ttlMs, TEMPLATE_PLAN_CACHE_TTL_MS);
|
|
264
266
|
return { maxEntries, ttlMs };
|
|
265
267
|
}
|
|
266
|
-
function createBindBenchSession() {
|
|
267
|
-
const enabled = isInDevMode() && globalThis[BENCH_FLAG] === true;
|
|
268
|
-
return {
|
|
269
|
-
enabled,
|
|
270
|
-
scanMs: 0,
|
|
271
|
-
parseMs: 0,
|
|
272
|
-
totalExpressions: 0,
|
|
273
|
-
fastPathExpressions: 0,
|
|
274
|
-
planCacheHit: false,
|
|
275
|
-
};
|
|
276
|
-
}
|
|
277
|
-
function flushBindBenchSession(session) {
|
|
278
|
-
if (!session.enabled)
|
|
279
|
-
return;
|
|
280
|
-
const stats = {
|
|
281
|
-
scanMs: Number(session.scanMs.toFixed(3)),
|
|
282
|
-
parseMs: Number(session.parseMs.toFixed(3)),
|
|
283
|
-
totalExpressions: session.totalExpressions,
|
|
284
|
-
fastPathExpressions: session.fastPathExpressions,
|
|
285
|
-
fastPathHitPercent: session.totalExpressions === 0
|
|
286
|
-
? 0
|
|
287
|
-
: Number(((session.fastPathExpressions / session.totalExpressions) * 100).toFixed(2)),
|
|
288
|
-
planCacheHit: session.planCacheHit,
|
|
289
|
-
};
|
|
290
|
-
const globalObj = globalThis;
|
|
291
|
-
const bucket = globalObj[BENCH_STATS_KEY] ?? {};
|
|
292
|
-
const runs = Array.isArray(bucket.runs) ? bucket.runs : [];
|
|
293
|
-
runs.push(stats);
|
|
294
|
-
if (runs.length > 200)
|
|
295
|
-
runs.shift();
|
|
296
|
-
bucket.runs = runs;
|
|
297
|
-
bucket.last = stats;
|
|
298
|
-
globalObj[BENCH_STATS_KEY] = bucket;
|
|
299
|
-
}
|
|
300
268
|
function compileFastPathExpression(expression) {
|
|
301
269
|
let i = 0;
|
|
302
270
|
const literalKeywords = {
|
|
@@ -788,22 +756,15 @@ function compileInterpolationExpression(expression) {
|
|
|
788
756
|
}
|
|
789
757
|
return { kind: 'parser', expression };
|
|
790
758
|
}
|
|
791
|
-
function parseInterpolationExpression(expression
|
|
759
|
+
function parseInterpolationExpression(expression) {
|
|
792
760
|
let ast = expressionCache.get(expression);
|
|
793
761
|
if (ast === undefined) {
|
|
794
|
-
const parseStart = benchSession?.enabled ? nowMs() : 0;
|
|
795
762
|
try {
|
|
796
763
|
ast = parseExpression(expression);
|
|
797
764
|
expressionCache.set(expression, ast);
|
|
798
|
-
if (benchSession?.enabled) {
|
|
799
|
-
benchSession.parseMs += nowMs() - parseStart;
|
|
800
|
-
}
|
|
801
765
|
}
|
|
802
766
|
catch (err) {
|
|
803
767
|
expressionCache.set(expression, null);
|
|
804
|
-
if (benchSession?.enabled) {
|
|
805
|
-
benchSession.parseMs += nowMs() - parseStart;
|
|
806
|
-
}
|
|
807
768
|
return {
|
|
808
769
|
ok: false,
|
|
809
770
|
message: `Text interpolation parse error in "{${expression}}": ${err.message}`,
|
|
@@ -1029,11 +990,11 @@ function createInterpolationTemplatePlan(root, rawTextSelectors) {
|
|
|
1029
990
|
fastPathExpressions,
|
|
1030
991
|
};
|
|
1031
992
|
}
|
|
1032
|
-
function resolveCompiledExpression(compiled
|
|
993
|
+
function resolveCompiledExpression(compiled) {
|
|
1033
994
|
if (compiled.kind === 'fast_path') {
|
|
1034
995
|
return { ok: true, ast: compiled.ast };
|
|
1035
996
|
}
|
|
1036
|
-
return parseInterpolationExpression(compiled.expression
|
|
997
|
+
return parseInterpolationExpression(compiled.expression);
|
|
1037
998
|
}
|
|
1038
999
|
function pruneTemplatePlanCache(now, config) {
|
|
1039
1000
|
// 1) Remove expired plans first.
|
|
@@ -1079,7 +1040,7 @@ function setCachedTemplatePlan(signature, plan, now, config) {
|
|
|
1079
1040
|
});
|
|
1080
1041
|
pruneTemplatePlanCache(now, config);
|
|
1081
1042
|
}
|
|
1082
|
-
function bindTextNodeFromPlan(node, plan, ctx
|
|
1043
|
+
function bindTextNodeFromPlan(node, plan, ctx) {
|
|
1083
1044
|
const frag = document.createDocumentFragment();
|
|
1084
1045
|
for (const segment of plan.segments) {
|
|
1085
1046
|
if (segment.type === 'text') {
|
|
@@ -1114,7 +1075,7 @@ function bindTextNodeFromPlan(node, plan, ctx, benchSession) {
|
|
|
1114
1075
|
}
|
|
1115
1076
|
textNode.data = result.value == null ? '' : String(result.value);
|
|
1116
1077
|
};
|
|
1117
|
-
const parsed = resolveCompiledExpression(segment.compiled
|
|
1078
|
+
const parsed = resolveCompiledExpression(segment.compiled);
|
|
1118
1079
|
if (!parsed.ok) {
|
|
1119
1080
|
applyResult({ ok: false, reason: 'parse', message: parsed.message });
|
|
1120
1081
|
frag.appendChild(textNode);
|
|
@@ -1134,23 +1095,14 @@ function bindTextNodeFromPlan(node, plan, ctx, benchSession) {
|
|
|
1134
1095
|
node.parentNode.replaceChild(frag, node);
|
|
1135
1096
|
}
|
|
1136
1097
|
}
|
|
1137
|
-
function bindTextInterpolation(root, ctx, rawTextSelectors, cacheConfig
|
|
1098
|
+
function bindTextInterpolation(root, ctx, rawTextSelectors, cacheConfig) {
|
|
1138
1099
|
const signature = createInterpolationTemplateSignature(root, rawTextSelectors);
|
|
1139
1100
|
const now = nowMs();
|
|
1140
1101
|
let plan = getCachedTemplatePlan(signature, now, cacheConfig);
|
|
1141
|
-
const scanStart = benchSession?.enabled ? nowMs() : 0;
|
|
1142
1102
|
if (!plan) {
|
|
1143
1103
|
plan = createInterpolationTemplatePlan(root, rawTextSelectors);
|
|
1144
1104
|
setCachedTemplatePlan(signature, plan, now, cacheConfig);
|
|
1145
1105
|
}
|
|
1146
|
-
else if (benchSession) {
|
|
1147
|
-
benchSession.planCacheHit = true;
|
|
1148
|
-
}
|
|
1149
|
-
if (benchSession?.enabled) {
|
|
1150
|
-
benchSession.scanMs += nowMs() - scanStart;
|
|
1151
|
-
benchSession.totalExpressions = plan.totalExpressions;
|
|
1152
|
-
benchSession.fastPathExpressions = plan.fastPathExpressions;
|
|
1153
|
-
}
|
|
1154
1106
|
if (plan.bindings.length === 0)
|
|
1155
1107
|
return;
|
|
1156
1108
|
const nodesToBind = [];
|
|
@@ -1161,7 +1113,7 @@ function bindTextInterpolation(root, ctx, rawTextSelectors, cacheConfig, benchSe
|
|
|
1161
1113
|
}
|
|
1162
1114
|
}
|
|
1163
1115
|
for (const item of nodesToBind) {
|
|
1164
|
-
bindTextNodeFromPlan(item.node, item.binding, ctx
|
|
1116
|
+
bindTextNodeFromPlan(item.node, item.binding, ctx);
|
|
1165
1117
|
}
|
|
1166
1118
|
}
|
|
1167
1119
|
// ============================================================================
|
|
@@ -1187,8 +1139,11 @@ function bindEvents(root, ctx, events, cleanups) {
|
|
|
1187
1139
|
warn(`Event handler "${handlerName}" is not a function`);
|
|
1188
1140
|
continue;
|
|
1189
1141
|
}
|
|
1190
|
-
|
|
1191
|
-
|
|
1142
|
+
const wrappedHandler = function (event) {
|
|
1143
|
+
return withSchedulerPriority('high', () => handler.call(this, event), { warnOnAsync: false });
|
|
1144
|
+
};
|
|
1145
|
+
el.addEventListener(eventName, wrappedHandler);
|
|
1146
|
+
cleanups.push(() => el.removeEventListener(eventName, wrappedHandler));
|
|
1192
1147
|
}
|
|
1193
1148
|
}
|
|
1194
1149
|
}
|
|
@@ -2279,6 +2234,8 @@ function bindVirtualEach(root, ctx, cleanups) {
|
|
|
2279
2234
|
}
|
|
2280
2235
|
};
|
|
2281
2236
|
function renderVirtualList(items) {
|
|
2237
|
+
if (virtualListDisposed)
|
|
2238
|
+
return;
|
|
2282
2239
|
const parent = comment.parentNode;
|
|
2283
2240
|
if (!parent)
|
|
2284
2241
|
return;
|
|
@@ -2393,34 +2350,33 @@ function bindVirtualEach(root, ctx, cleanups) {
|
|
|
2393
2350
|
maybeTriggerEndReached(visibleEndForEndReached, items.length);
|
|
2394
2351
|
}
|
|
2395
2352
|
let framePending = false;
|
|
2396
|
-
let
|
|
2397
|
-
let pendingTimeout = null;
|
|
2353
|
+
let virtualListDisposed = false;
|
|
2398
2354
|
const scheduleRender = () => {
|
|
2355
|
+
if (virtualListDisposed)
|
|
2356
|
+
return;
|
|
2399
2357
|
if (framePending)
|
|
2400
2358
|
return;
|
|
2401
2359
|
framePending = true;
|
|
2402
|
-
const
|
|
2360
|
+
const listRenderPriority = resolveListRenderPriority();
|
|
2361
|
+
schedule(() => {
|
|
2403
2362
|
framePending = false;
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
pendingRaf = null;
|
|
2409
|
-
flush();
|
|
2363
|
+
if (virtualListDisposed)
|
|
2364
|
+
return;
|
|
2365
|
+
withSchedulerPriority(listRenderPriority, () => {
|
|
2366
|
+
renderVirtualList(currentItems);
|
|
2410
2367
|
});
|
|
2411
|
-
|
|
2412
|
-
}
|
|
2413
|
-
pendingTimeout = setTimeout(() => {
|
|
2414
|
-
pendingTimeout = null;
|
|
2415
|
-
flush();
|
|
2416
|
-
}, 0);
|
|
2368
|
+
}, { priority: listRenderPriority });
|
|
2417
2369
|
};
|
|
2418
2370
|
const onScroll = () => scheduleRender();
|
|
2419
2371
|
const onResize = () => scheduleRender();
|
|
2420
2372
|
scrollContainer?.addEventListener('scroll', onScroll, { passive: true });
|
|
2421
2373
|
let containerResizeObserver = null;
|
|
2422
2374
|
if (typeof ResizeObserver !== 'undefined' && scrollContainer) {
|
|
2423
|
-
containerResizeObserver = new ResizeObserver(() =>
|
|
2375
|
+
containerResizeObserver = new ResizeObserver(() => {
|
|
2376
|
+
if (virtualListDisposed)
|
|
2377
|
+
return;
|
|
2378
|
+
scheduleRender();
|
|
2379
|
+
});
|
|
2424
2380
|
containerResizeObserver.observe(scrollContainer);
|
|
2425
2381
|
}
|
|
2426
2382
|
else if (typeof window !== 'undefined') {
|
|
@@ -2428,6 +2384,8 @@ function bindVirtualEach(root, ctx, cleanups) {
|
|
|
2428
2384
|
}
|
|
2429
2385
|
if (dynamicHeight && typeof ResizeObserver !== 'undefined' && heightsIndex) {
|
|
2430
2386
|
rowResizeObserver = new ResizeObserver((entries) => {
|
|
2387
|
+
if (virtualListDisposed)
|
|
2388
|
+
return;
|
|
2431
2389
|
let changed = false;
|
|
2432
2390
|
for (const entry of entries) {
|
|
2433
2391
|
const target = entry.target;
|
|
@@ -2483,6 +2441,7 @@ function bindVirtualEach(root, ctx, cleanups) {
|
|
|
2483
2441
|
scrollContainer.__dalilaVirtualList = virtualApi;
|
|
2484
2442
|
}
|
|
2485
2443
|
if (isSignal(binding)) {
|
|
2444
|
+
let hasRenderedInitialSignalPass = false;
|
|
2486
2445
|
bindEffect(scrollContainer ?? el, () => {
|
|
2487
2446
|
const value = binding();
|
|
2488
2447
|
if (Array.isArray(value)) {
|
|
@@ -2496,17 +2455,30 @@ function bindVirtualEach(root, ctx, cleanups) {
|
|
|
2496
2455
|
}
|
|
2497
2456
|
replaceItems([]);
|
|
2498
2457
|
}
|
|
2499
|
-
|
|
2458
|
+
if (!hasRenderedInitialSignalPass) {
|
|
2459
|
+
hasRenderedInitialSignalPass = true;
|
|
2460
|
+
const listRenderPriority = resolveListRenderPriority();
|
|
2461
|
+
withSchedulerPriority(listRenderPriority, () => {
|
|
2462
|
+
renderVirtualList(currentItems);
|
|
2463
|
+
});
|
|
2464
|
+
return;
|
|
2465
|
+
}
|
|
2466
|
+
scheduleRender();
|
|
2500
2467
|
});
|
|
2501
2468
|
}
|
|
2502
2469
|
else if (Array.isArray(binding)) {
|
|
2503
2470
|
replaceItems(binding);
|
|
2504
|
-
|
|
2471
|
+
const listRenderPriority = resolveListRenderPriority();
|
|
2472
|
+
withSchedulerPriority(listRenderPriority, () => {
|
|
2473
|
+
renderVirtualList(currentItems);
|
|
2474
|
+
});
|
|
2505
2475
|
}
|
|
2506
2476
|
else {
|
|
2507
2477
|
warn(`d-virtual-each: "${bindingName}" is not an array or signal-of-array`);
|
|
2508
2478
|
}
|
|
2509
2479
|
cleanups.push(() => {
|
|
2480
|
+
virtualListDisposed = true;
|
|
2481
|
+
framePending = false;
|
|
2510
2482
|
scrollContainer?.removeEventListener('scroll', onScroll);
|
|
2511
2483
|
if (containerResizeObserver) {
|
|
2512
2484
|
containerResizeObserver.disconnect();
|
|
@@ -2514,13 +2486,6 @@ function bindVirtualEach(root, ctx, cleanups) {
|
|
|
2514
2486
|
else if (typeof window !== 'undefined') {
|
|
2515
2487
|
window.removeEventListener('resize', onResize);
|
|
2516
2488
|
}
|
|
2517
|
-
if (pendingRaf != null && typeof cancelAnimationFrame === 'function') {
|
|
2518
|
-
cancelAnimationFrame(pendingRaf);
|
|
2519
|
-
}
|
|
2520
|
-
pendingRaf = null;
|
|
2521
|
-
if (pendingTimeout != null)
|
|
2522
|
-
clearTimeout(pendingTimeout);
|
|
2523
|
-
pendingTimeout = null;
|
|
2524
2489
|
if (rowResizeObserver) {
|
|
2525
2490
|
rowResizeObserver.disconnect();
|
|
2526
2491
|
}
|
|
@@ -2759,20 +2724,62 @@ function bindEach(root, ctx, cleanups) {
|
|
|
2759
2724
|
referenceNode = clone;
|
|
2760
2725
|
}
|
|
2761
2726
|
}
|
|
2727
|
+
let lowPriorityRenderQueued = false;
|
|
2728
|
+
let listRenderDisposed = false;
|
|
2729
|
+
let pendingListItems = null;
|
|
2730
|
+
const scheduleLowPriorityListRender = (items) => {
|
|
2731
|
+
if (listRenderDisposed)
|
|
2732
|
+
return;
|
|
2733
|
+
const listRenderPriority = resolveListRenderPriority();
|
|
2734
|
+
pendingListItems = items;
|
|
2735
|
+
if (lowPriorityRenderQueued)
|
|
2736
|
+
return;
|
|
2737
|
+
lowPriorityRenderQueued = true;
|
|
2738
|
+
schedule(() => {
|
|
2739
|
+
lowPriorityRenderQueued = false;
|
|
2740
|
+
if (listRenderDisposed) {
|
|
2741
|
+
pendingListItems = null;
|
|
2742
|
+
return;
|
|
2743
|
+
}
|
|
2744
|
+
const next = pendingListItems;
|
|
2745
|
+
pendingListItems = null;
|
|
2746
|
+
if (!next)
|
|
2747
|
+
return;
|
|
2748
|
+
withSchedulerPriority(listRenderPriority, () => {
|
|
2749
|
+
renderList(next);
|
|
2750
|
+
});
|
|
2751
|
+
}, { priority: listRenderPriority });
|
|
2752
|
+
};
|
|
2762
2753
|
if (isSignal(binding)) {
|
|
2754
|
+
let hasRenderedInitialSignalPass = false;
|
|
2763
2755
|
// Effect owned by templateScope — no manual stop needed
|
|
2764
2756
|
bindEffect(el, () => {
|
|
2765
2757
|
const value = binding();
|
|
2766
|
-
|
|
2758
|
+
const items = Array.isArray(value) ? value : [];
|
|
2759
|
+
if (!hasRenderedInitialSignalPass) {
|
|
2760
|
+
hasRenderedInitialSignalPass = true;
|
|
2761
|
+
const listRenderPriority = resolveListRenderPriority();
|
|
2762
|
+
withSchedulerPriority(listRenderPriority, () => {
|
|
2763
|
+
renderList(items);
|
|
2764
|
+
});
|
|
2765
|
+
return;
|
|
2766
|
+
}
|
|
2767
|
+
scheduleLowPriorityListRender(items);
|
|
2767
2768
|
});
|
|
2768
2769
|
}
|
|
2769
2770
|
else if (Array.isArray(binding)) {
|
|
2770
|
-
|
|
2771
|
+
const listRenderPriority = resolveListRenderPriority();
|
|
2772
|
+
withSchedulerPriority(listRenderPriority, () => {
|
|
2773
|
+
renderList(binding);
|
|
2774
|
+
});
|
|
2771
2775
|
}
|
|
2772
2776
|
else {
|
|
2773
2777
|
warn(`d-each: "${bindingName}" is not an array or signal`);
|
|
2774
2778
|
}
|
|
2775
2779
|
cleanups.push(() => {
|
|
2780
|
+
listRenderDisposed = true;
|
|
2781
|
+
lowPriorityRenderQueued = false;
|
|
2782
|
+
pendingListItems = null;
|
|
2776
2783
|
for (const clone of clonesByKey.values())
|
|
2777
2784
|
clone.remove();
|
|
2778
2785
|
for (const dispose of disposesByKey.values())
|
|
@@ -3212,13 +3219,16 @@ function bindForm(root, ctx, cleanups) {
|
|
|
3212
3219
|
? originalHandler
|
|
3213
3220
|
: form.handleSubmit(originalHandler);
|
|
3214
3221
|
// Add submit listener directly to form element (not via d-on-submit)
|
|
3215
|
-
// This avoids mutating the shared context
|
|
3216
|
-
|
|
3222
|
+
// This avoids mutating the shared context while preserving high-priority event context.
|
|
3223
|
+
const wrappedSubmitHandler = function (event) {
|
|
3224
|
+
return withSchedulerPriority('high', () => finalHandler.call(this, event), { warnOnAsync: false });
|
|
3225
|
+
};
|
|
3226
|
+
el.addEventListener('submit', wrappedSubmitHandler);
|
|
3217
3227
|
// Remove d-on-submit to prevent bindEvents from adding duplicate listener
|
|
3218
3228
|
el.removeAttribute('d-on-submit');
|
|
3219
3229
|
// Restore attribute on cleanup so dispose()+bind() (HMR) can rediscover it
|
|
3220
3230
|
cleanups.push(() => {
|
|
3221
|
-
el.removeEventListener('submit',
|
|
3231
|
+
el.removeEventListener('submit', wrappedSubmitHandler);
|
|
3222
3232
|
el.setAttribute('d-on-submit', submitHandlerName);
|
|
3223
3233
|
});
|
|
3224
3234
|
}
|
|
@@ -4181,7 +4191,6 @@ export function bind(root, ctx, options = {}) {
|
|
|
4181
4191
|
const rawTextSelectors = options.rawTextSelectors ?? DEFAULT_RAW_TEXT_SELECTORS;
|
|
4182
4192
|
const templatePlanCacheConfig = resolveTemplatePlanCacheConfig(options);
|
|
4183
4193
|
const transitionRegistry = createTransitionRegistry(options.transitions);
|
|
4184
|
-
const benchSession = createBindBenchSession();
|
|
4185
4194
|
const htmlRoot = root;
|
|
4186
4195
|
// HMR support: Register binding context globally in dev mode.
|
|
4187
4196
|
// Skip for internal (d-each clone) bindings — only the top-level bind owns HMR.
|
|
@@ -4217,7 +4226,7 @@ export function bind(root, ctx, options = {}) {
|
|
|
4217
4226
|
// 7.5. d-text — safe textContent binding (before text interpolation)
|
|
4218
4227
|
bindText(root, ctx, cleanups);
|
|
4219
4228
|
// 7. Text interpolation (template plan cache + lazy parser fallback)
|
|
4220
|
-
bindTextInterpolation(root, ctx, rawTextSelectors, templatePlanCacheConfig
|
|
4229
|
+
bindTextInterpolation(root, ctx, rawTextSelectors, templatePlanCacheConfig);
|
|
4221
4230
|
// 8. d-attr bindings
|
|
4222
4231
|
bindAttrs(root, ctx, cleanups);
|
|
4223
4232
|
// 9. d-bind-* two-way bindings
|
|
@@ -4256,7 +4265,6 @@ export function bind(root, ctx, options = {}) {
|
|
|
4256
4265
|
htmlRoot.setAttribute('d-ready', '');
|
|
4257
4266
|
});
|
|
4258
4267
|
}
|
|
4259
|
-
flushBindBenchSession(benchSession);
|
|
4260
4268
|
// Return BindHandle (callable dispose + ref accessors)
|
|
4261
4269
|
const dispose = () => {
|
|
4262
4270
|
// Run manual cleanups (event listeners)
|
package/dist/runtime/lazy.d.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* @module dalila/runtime/lazy
|
|
10
10
|
*/
|
|
11
11
|
import { signal } from '../core/index.js';
|
|
12
|
+
import { type SchedulerPriority } from '../core/scheduler.js';
|
|
12
13
|
import type { Component } from './component.js';
|
|
13
14
|
export interface LazyComponentOptions {
|
|
14
15
|
/** Loading fallback template */
|
|
@@ -26,7 +27,7 @@ export interface LazyComponentState {
|
|
|
26
27
|
/** The loaded component (if successful) */
|
|
27
28
|
component: ReturnType<typeof signal<Component | null>>;
|
|
28
29
|
/** Function to trigger loading */
|
|
29
|
-
load: () => void;
|
|
30
|
+
load: (priority?: SchedulerPriority) => void;
|
|
30
31
|
/** Function to retry after error */
|
|
31
32
|
retry: () => void;
|
|
32
33
|
/** Whether the component has been loaded */
|
package/dist/runtime/lazy.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* @module dalila/runtime/lazy
|
|
10
10
|
*/
|
|
11
11
|
import { signal } from '../core/index.js';
|
|
12
|
+
import { withSchedulerPriority } from '../core/scheduler.js';
|
|
12
13
|
import { defineComponent, isComponent } from './component.js';
|
|
13
14
|
// ============================================================================
|
|
14
15
|
// Lazy Component Registry
|
|
@@ -53,47 +54,57 @@ export function createLazyComponent(loader, options = {}) {
|
|
|
53
54
|
const loadedSignal = signal(false);
|
|
54
55
|
let loadingTimeout = null;
|
|
55
56
|
let loadInFlight = false;
|
|
56
|
-
const load = () => {
|
|
57
|
+
const load = (priority = 'medium') => {
|
|
57
58
|
if (loadedSignal() || loadInFlight)
|
|
58
59
|
return;
|
|
59
60
|
loadInFlight = true;
|
|
60
61
|
// Handle loading delay
|
|
61
62
|
if (loadingDelay > 0) {
|
|
62
63
|
loadingTimeout = setTimeout(() => {
|
|
63
|
-
|
|
64
|
+
withSchedulerPriority(priority, () => {
|
|
65
|
+
loadingSignal.set(true);
|
|
66
|
+
}, { warnOnAsync: false });
|
|
64
67
|
}, loadingDelay);
|
|
65
68
|
}
|
|
66
69
|
else {
|
|
67
|
-
|
|
70
|
+
withSchedulerPriority(priority, () => {
|
|
71
|
+
loadingSignal.set(true);
|
|
72
|
+
}, { warnOnAsync: false });
|
|
68
73
|
}
|
|
69
|
-
|
|
74
|
+
withSchedulerPriority(priority, () => {
|
|
75
|
+
errorSignal.set(null);
|
|
76
|
+
}, { warnOnAsync: false });
|
|
70
77
|
Promise.resolve()
|
|
71
|
-
.then(() => loader())
|
|
78
|
+
.then(() => withSchedulerPriority(priority, () => loader(), { warnOnAsync: false }))
|
|
72
79
|
.then((module) => {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
80
|
+
withSchedulerPriority(priority, () => {
|
|
81
|
+
// Clear timeout if still pending
|
|
82
|
+
if (loadingTimeout) {
|
|
83
|
+
clearTimeout(loadingTimeout);
|
|
84
|
+
loadingTimeout = null;
|
|
85
|
+
}
|
|
86
|
+
const loadedComp = isComponent(module)
|
|
87
|
+
? module
|
|
88
|
+
: ('default' in module ? module.default : null);
|
|
89
|
+
if (!loadedComp) {
|
|
90
|
+
throw new Error('Lazy component: failed to load component from module');
|
|
91
|
+
}
|
|
92
|
+
componentSignal.set(loadedComp);
|
|
93
|
+
loadingSignal.set(false);
|
|
94
|
+
loadedSignal.set(true);
|
|
95
|
+
loadInFlight = false;
|
|
96
|
+
}, { warnOnAsync: false });
|
|
88
97
|
})
|
|
89
98
|
.catch((err) => {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
99
|
+
withSchedulerPriority(priority, () => {
|
|
100
|
+
if (loadingTimeout) {
|
|
101
|
+
clearTimeout(loadingTimeout);
|
|
102
|
+
loadingTimeout = null;
|
|
103
|
+
}
|
|
104
|
+
errorSignal.set(err instanceof Error ? err : new Error(String(err)));
|
|
105
|
+
loadingSignal.set(false);
|
|
106
|
+
loadInFlight = false;
|
|
107
|
+
}, { warnOnAsync: false });
|
|
97
108
|
});
|
|
98
109
|
};
|
|
99
110
|
const retry = () => {
|
|
@@ -177,7 +188,7 @@ export function createSuspense(options = {}) {
|
|
|
177
188
|
export function preloadLazyComponent(tag) {
|
|
178
189
|
const lazyResult = lazyComponentRegistry.get(tag);
|
|
179
190
|
if (lazyResult) {
|
|
180
|
-
lazyResult.state.load();
|
|
191
|
+
lazyResult.state.load('low');
|
|
181
192
|
}
|
|
182
193
|
}
|
|
183
194
|
/**
|