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 CHANGED
@@ -15,12 +15,14 @@ npm run dev
15
15
 
16
16
  Open http://localhost:4242 to see your app.
17
17
 
18
- ## Manual Installation
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
- ### Getting Started
49
+ ### Start here
48
50
 
49
- - [Overview](./docs/index.md) — Philosophy and quick start
50
- - [Template Spec](./docs/template-spec.md) — Binding syntax reference
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) — `signal`, `computed`, `effect`
55
- - [Scopes](./docs/core/scope.md) — Lifecycle management and cleanup
56
- - [Persist](./docs/core/persist.md) — Automatic storage sync for signals
57
- - [Context](./docs/context.md) — Dependency injection
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) — `bind()`, `mount()`, `configure()`, transitions, portal, text interpolation, events
62
- - [Components](./docs/runtime/component.md) — `defineComponent`, typed props/emits/refs, slots
63
- - [Lazy Loading](./docs/runtime/lazy.md) — `createLazyComponent`, `d-lazy`, `createSuspense` wrapper, code splitting
64
- - [Error Boundary](./docs/runtime/boundary.md) — `createErrorBoundary`, `createErrorBoundaryState`, `withErrorBoundary`, `d-boundary`
65
- - [FOUC Prevention](./docs/runtime/fouc-prevention.md) — Automatic token hiding
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
- ### Forms
76
+ ### Rendering & Data
94
77
 
95
- - [Forms](./docs/forms.md) — DOM-first form management with validation, field arrays, and accessibility
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
- ### Utilities
86
+ ### Tooling
98
87
 
99
- - [Scheduler](./docs/core/scheduler.md) — Batching and coordination
100
- - [Keys](./docs/core/key.md) — Cache key encoding
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
- ## Features
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
- src/app/
238
- ├── layout.html
239
- ├── page.html
240
- ├── about/
241
- │ └── page.html
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">&times;</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
@@ -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
  *
@@ -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
- const microtaskQueue = [];
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.length > 0 && iterations < maxIterations) {
213
+ while (hasQueuedTasks(microtaskQueue) && iterations < maxIterations) {
181
214
  iterations++;
182
- const tasks = microtaskQueue.splice(0);
183
- for (const t of tasks)
184
- t();
215
+ drainPriorityCycle(microtaskQueue);
185
216
  }
186
- if (iterations >= maxIterations && microtaskQueue.length > 0) {
217
+ if (iterations >= maxIterations && hasQueuedTasks(microtaskQueue)) {
187
218
  console.error(`[Dalila] Scheduler exceeded ${maxIterations} microtask iterations. ` +
188
- `Possible infinite loop detected. Remaining ${microtaskQueue.length} tasks discarded.`);
189
- microtaskQueue.length = 0;
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.length > 0 && !microtaskScheduled) {
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.length > 0 && iterations < maxIterations) {
247
+ while (hasQueuedTasks(rafQueue) && iterations < maxIterations) {
217
248
  iterations++;
218
- const tasks = rafQueue.splice(0);
219
- for (const t of tasks)
220
- t();
249
+ drainPriorityCycle(rafQueue);
221
250
  }
222
- if (iterations >= maxIterations && rafQueue.length > 0) {
251
+ if (iterations >= maxIterations && hasQueuedTasks(rafQueue)) {
223
252
  console.error(`[Dalila] Scheduler exceeded ${maxIterations} RAF iterations. ` +
224
- `Possible infinite loop detected. Remaining ${rafQueue.length} tasks discarded.`);
225
- rafQueue.length = 0;
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.length > 0 && !rafScheduled) {
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
  *
@@ -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)
@@ -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
- if (!controller.signal.aborted) {
1465
- entry.status = 'fulfilled';
1466
- entry.data = data;
1467
- }
1468
- preloadScope.dispose();
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
- if (!isAbortError(error)) {
1472
- entry.status = 'rejected';
1473
- entry.error = error;
1474
- }
1475
- preloadScope.dispose();
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 normalizedTag = tag.trim();
1490
- if (!normalizedTag)
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
- const candidates = collectPrefetchCandidates().filter((candidate) => candidate.tags.includes(normalizedTag));
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 threshold = Number(minScore);
1499
- const candidates = collectPrefetchCandidates().filter((candidate) => {
1500
- if (typeof candidate.score !== 'number')
1501
- return false;
1502
- return candidate.score >= threshold;
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)
@@ -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, benchSession) {
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, benchSession) {
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, benchSession);
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, benchSession) {
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, benchSession);
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, benchSession) {
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, benchSession);
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
- el.addEventListener(eventName, handler);
1191
- cleanups.push(() => el.removeEventListener(eventName, handler));
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 pendingRaf = null;
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 flush = () => {
2360
+ const listRenderPriority = resolveListRenderPriority();
2361
+ schedule(() => {
2403
2362
  framePending = false;
2404
- renderVirtualList(currentItems);
2405
- };
2406
- if (typeof requestAnimationFrame === 'function') {
2407
- pendingRaf = requestAnimationFrame(() => {
2408
- pendingRaf = null;
2409
- flush();
2363
+ if (virtualListDisposed)
2364
+ return;
2365
+ withSchedulerPriority(listRenderPriority, () => {
2366
+ renderVirtualList(currentItems);
2410
2367
  });
2411
- return;
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(() => scheduleRender());
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
- renderVirtualList(currentItems);
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
- renderVirtualList(currentItems);
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
- renderList(Array.isArray(value) ? value : []);
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
- renderList(binding);
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
- el.addEventListener('submit', finalHandler);
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', finalHandler);
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, benchSession);
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)
@@ -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 */
@@ -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
- loadingSignal.set(true);
64
+ withSchedulerPriority(priority, () => {
65
+ loadingSignal.set(true);
66
+ }, { warnOnAsync: false });
64
67
  }, loadingDelay);
65
68
  }
66
69
  else {
67
- loadingSignal.set(true);
70
+ withSchedulerPriority(priority, () => {
71
+ loadingSignal.set(true);
72
+ }, { warnOnAsync: false });
68
73
  }
69
- errorSignal.set(null);
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
- // Clear timeout if still pending
74
- if (loadingTimeout) {
75
- clearTimeout(loadingTimeout);
76
- loadingTimeout = null;
77
- }
78
- const loadedComp = isComponent(module)
79
- ? module
80
- : ('default' in module ? module.default : null);
81
- if (!loadedComp) {
82
- throw new Error('Lazy component: failed to load component from module');
83
- }
84
- componentSignal.set(loadedComp);
85
- loadingSignal.set(false);
86
- loadedSignal.set(true);
87
- loadInFlight = false;
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
- if (loadingTimeout) {
91
- clearTimeout(loadingTimeout);
92
- loadingTimeout = null;
93
- }
94
- errorSignal.set(err instanceof Error ? err : new Error(String(err)));
95
- loadingSignal.set(false);
96
- loadInFlight = false;
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
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dalila",
3
- "version": "1.9.18",
3
+ "version": "1.9.19",
4
4
  "description": "DOM-first reactive framework based on signals",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",