dalila 1.9.8 → 1.9.10

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
@@ -60,6 +60,8 @@ bind(document.getElementById('app')!, ctx);
60
60
 
61
61
  - [Template Binding](./docs/runtime/bind.md) — `bind()`, `mount()`, `configure()`, transitions, portal, text interpolation, events
62
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`
63
65
  - [FOUC Prevention](./docs/runtime/fouc-prevention.md) — Automatic token hiding
64
66
 
65
67
  ### Routing
package/dist/core/for.js CHANGED
@@ -178,6 +178,8 @@ export function forEach(items, template, keyFn) {
178
178
  }
179
179
  };
180
180
  let hasValidatedOnce = false;
181
+ const reusableOldMap = new Map();
182
+ const reusableSeenNextKeys = new Set();
181
183
  const throwDuplicateKey = (key, scheduleFatal) => {
182
184
  const error = new Error(`[Dalila] Duplicate key "${key}" detected in forEach. ` +
183
185
  `Keys must be unique within the same list. Check your keyFn implementation.`);
@@ -231,43 +233,42 @@ export function forEach(items, template, keyFn) {
231
233
  const newItems = items();
232
234
  // Validate again on updates (will be caught by effect error handler)
233
235
  validateNoDuplicateKeys(newItems);
234
- const oldMap = new Map();
235
- currentItems.forEach(item => oldMap.set(item.key, item));
236
+ reusableOldMap.clear();
237
+ currentItems.forEach(item => reusableOldMap.set(item.key, item));
236
238
  const nextItems = [];
237
- const itemsToUpdate = new Set();
238
- const seenNextKeys = new Set();
239
+ reusableSeenNextKeys.clear();
239
240
  // Phase 1: Build next list + detect updates/new
240
241
  newItems.forEach((item, index) => {
241
242
  const key = getKey(item, index);
242
- if (seenNextKeys.has(key))
243
+ if (reusableSeenNextKeys.has(key))
243
244
  return; // prod-mode: ignore dup keys silently
244
- seenNextKeys.add(key);
245
- const existing = oldMap.get(key);
245
+ reusableSeenNextKeys.add(key);
246
+ const existing = reusableOldMap.get(key);
246
247
  if (existing) {
248
+ reusableOldMap.delete(key);
247
249
  if (existing.value !== item) {
248
- itemsToUpdate.add(key);
249
250
  existing.value = item;
251
+ existing.dirty = true;
250
252
  }
251
253
  nextItems.push(existing);
252
254
  }
253
255
  else {
254
- itemsToUpdate.add(key);
255
- nextItems.push({
256
+ const created = {
256
257
  key,
257
258
  value: item,
258
259
  start: document.createComment(`for:${key}:start`),
259
260
  end: document.createComment(`for:${key}:end`),
260
261
  scope: null,
261
- indexSignal: signal(index)
262
- });
262
+ indexSignal: signal(index),
263
+ dirty: true,
264
+ };
265
+ nextItems.push(created);
263
266
  }
264
267
  });
265
268
  // Phase 2: Remove items no longer present
266
- const nextKeys = new Set(nextItems.map(i => i.key));
267
- currentItems.forEach(item => {
268
- if (!nextKeys.has(item.key))
269
- removeRange(item);
270
- });
269
+ for (const staleItem of reusableOldMap.values()) {
270
+ removeRange(staleItem);
271
+ }
271
272
  // Phase 3: Move/insert items to correct positions
272
273
  const parent = end.parentNode;
273
274
  if (parent) {
@@ -288,18 +289,20 @@ export function forEach(items, template, keyFn) {
288
289
  }
289
290
  // Phase 4: Dispose scopes and clear content for changed items
290
291
  nextItems.forEach(item => {
291
- if (!itemsToUpdate.has(item.key))
292
+ if (!item.dirty)
292
293
  return;
293
294
  disposeItemScope(item);
294
295
  clearBetween(item.start, item.end);
295
296
  });
296
- // Phase 5: Update reactive indices for ALL items
297
+ // Phase 5: Update reactive indices only when index actually changes
297
298
  nextItems.forEach((item, index) => {
298
- item.indexSignal.set(index);
299
+ if (item.indexSignal.peek() !== index) {
300
+ item.indexSignal.set(index);
301
+ }
299
302
  });
300
303
  // Phase 6: Render changed items
301
304
  nextItems.forEach(item => {
302
- if (!itemsToUpdate.has(item.key))
305
+ if (!item.dirty)
303
306
  return;
304
307
  item.scope = createScope();
305
308
  withScope(item.scope, () => {
@@ -308,6 +311,7 @@ export function forEach(items, template, keyFn) {
308
311
  const nodes = Array.isArray(templateResult) ? templateResult : [templateResult];
309
312
  item.end.before(...nodes);
310
313
  });
314
+ item.dirty = false;
311
315
  });
312
316
  currentItems = nextItems;
313
317
  };
@@ -44,16 +44,6 @@ let activeScope = null;
44
44
  * Per-tick dedupe for async effects.
45
45
  * Multiple writes in the same tick schedule an effect only once.
46
46
  */
47
- const pendingEffects = new Set();
48
- /**
49
- * Stable runner per effect (function identity).
50
- *
51
- * Why:
52
- * - when batching, we enqueue a function into the batch queue
53
- * - dedupe uses function identity
54
- * - each effect needs a stable runner function across schedules
55
- */
56
- const effectRunners = new WeakMap();
57
47
  /**
58
48
  * Schedule an effect with correct semantics:
59
49
  * - computed invalidations run synchronously (mark dirty immediately)
@@ -73,15 +63,12 @@ function scheduleEffect(eff) {
73
63
  }
74
64
  return;
75
65
  }
76
- // Dedup before scheduling.
77
- if (pendingEffects.has(eff))
66
+ if (eff.queued)
78
67
  return;
79
- pendingEffects.add(eff);
80
- // Create / reuse stable runner (so batch dedupe works correctly).
81
- let runEffect = effectRunners.get(eff);
82
- if (!runEffect) {
83
- runEffect = () => {
84
- pendingEffects.delete(eff);
68
+ eff.queued = true;
69
+ if (!eff.runner) {
70
+ eff.runner = () => {
71
+ eff.queued = false;
85
72
  if (eff.disposed)
86
73
  return;
87
74
  try {
@@ -91,14 +78,27 @@ function scheduleEffect(eff) {
91
78
  reportEffectError(error, 'effect');
92
79
  }
93
80
  };
94
- effectRunners.set(eff, runEffect);
95
81
  }
96
82
  // During batch: defer scheduling into the batch queue (no microtask overhead).
97
83
  // Outside batch: schedule in a microtask (coalescing across multiple writes).
98
84
  if (isBatching())
99
- queueInBatch(runEffect);
85
+ queueInBatch(eff.runner);
100
86
  else
101
- scheduleMicrotask(runEffect);
87
+ scheduleMicrotask(eff.runner);
88
+ }
89
+ function trySubscribeActiveEffect(subscribers, signalRef) {
90
+ if (!activeEffect || activeEffect.disposed)
91
+ return;
92
+ if (activeScope) {
93
+ const current = getCurrentScope();
94
+ if (activeScope !== current)
95
+ return;
96
+ }
97
+ if (subscribers.has(activeEffect))
98
+ return;
99
+ subscribers.add(activeEffect);
100
+ (activeEffect.deps ?? (activeEffect.deps = new Set())).add(subscribers);
101
+ trackDependency(signalRef, activeEffect);
102
102
  }
103
103
  /**
104
104
  * Create a signal: a mutable value with automatic dependency tracking.
@@ -121,28 +121,7 @@ export function signal(initialValue) {
121
121
  let signalRef;
122
122
  const read = () => {
123
123
  trackSignalRead(signalRef);
124
- if (activeEffect && !activeEffect.disposed) {
125
- // Scope-aware subscription guard (best effort):
126
- // - if the active effect is not scoped, allow subscription
127
- // - if scoped, only subscribe when currently executing inside that scope
128
- if (!activeScope) {
129
- if (!subscribers.has(activeEffect)) {
130
- subscribers.add(activeEffect);
131
- (activeEffect.deps ?? (activeEffect.deps = new Set())).add(subscribers);
132
- trackDependency(signalRef, activeEffect);
133
- }
134
- }
135
- else {
136
- const current = getCurrentScope();
137
- if (activeScope === current) {
138
- if (!subscribers.has(activeEffect)) {
139
- subscribers.add(activeEffect);
140
- (activeEffect.deps ?? (activeEffect.deps = new Set())).add(subscribers);
141
- trackDependency(signalRef, activeEffect);
142
- }
143
- }
144
- }
145
- }
124
+ trySubscribeActiveEffect(subscribers, signalRef);
146
125
  return value;
147
126
  };
148
127
  const notify = () => {
@@ -223,7 +202,7 @@ export function effect(fn) {
223
202
  return;
224
203
  run.disposed = true;
225
204
  cleanupDeps();
226
- pendingEffects.delete(run);
205
+ run.queued = false;
227
206
  trackEffectDispose(run);
228
207
  };
229
208
  const run = (() => {
@@ -302,25 +281,7 @@ export function computed(fn) {
302
281
  const read = () => {
303
282
  trackSignalRead(signalRef);
304
283
  // Allow effects to subscribe to this computed (same rules as signal()).
305
- if (activeEffect && !activeEffect.disposed) {
306
- if (!activeScope) {
307
- if (!subscribers.has(activeEffect)) {
308
- subscribers.add(activeEffect);
309
- (activeEffect.deps ?? (activeEffect.deps = new Set())).add(subscribers);
310
- trackDependency(signalRef, activeEffect);
311
- }
312
- }
313
- else {
314
- const current = getCurrentScope();
315
- if (activeScope === current) {
316
- if (!subscribers.has(activeEffect)) {
317
- subscribers.add(activeEffect);
318
- (activeEffect.deps ?? (activeEffect.deps = new Set())).add(subscribers);
319
- trackDependency(signalRef, activeEffect);
320
- }
321
- }
322
- }
323
- }
284
+ trySubscribeActiveEffect(subscribers, signalRef);
324
285
  if (dirty) {
325
286
  cleanupDeps();
326
287
  const prevEffect = activeEffect;
@@ -426,7 +387,7 @@ export function effectAsync(fn) {
426
387
  controller?.abort();
427
388
  controller = null;
428
389
  cleanupDeps();
429
- pendingEffects.delete(run);
390
+ run.queued = false;
430
391
  trackEffectDispose(run);
431
392
  };
432
393
  const run = (() => {
@@ -121,6 +121,11 @@ export interface RouteStackResult {
121
121
  export interface CompiledRoute {
122
122
  route: RouteTable;
123
123
  fullPath: string;
124
+ normalizedFullPath: string;
125
+ /** Static first segment of fullPath (null for dynamic/wildcard/root-first paths). */
126
+ firstStaticSegment: string | null;
127
+ staticExactPath: string | null;
128
+ staticPrefixPath: string | null;
124
129
  exactPattern: RegExp;
125
130
  /** null means root "/" which always matches as prefix */
126
131
  prefixPattern: RegExp | null;
@@ -92,6 +92,46 @@ function sortRoutes(routes) {
92
92
  function escapeRegexSegment(segment) {
93
93
  return segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
94
94
  }
95
+ const routeLevelIndexCache = new WeakMap();
96
+ function resolveFirstStaticSegment(path) {
97
+ const normalized = normalizePath(path);
98
+ const segments = normalized.split('/').filter(Boolean);
99
+ const first = segments[0];
100
+ if (!first)
101
+ return null;
102
+ if (first.startsWith(':') || first.includes('*'))
103
+ return null;
104
+ return first;
105
+ }
106
+ function isFullyStaticPath(path) {
107
+ const normalized = normalizePath(path);
108
+ const segments = normalized.split('/').filter(Boolean);
109
+ for (const segment of segments) {
110
+ if (segment.startsWith(':') || segment.includes('*'))
111
+ return false;
112
+ }
113
+ return true;
114
+ }
115
+ function buildRouteLevelIndex(compiled) {
116
+ const staticByFirstSegment = new Map();
117
+ const genericRoutes = [];
118
+ const staticParentRoutesWithChildren = [];
119
+ for (const entry of compiled) {
120
+ if (entry.firstStaticSegment) {
121
+ const bucket = staticByFirstSegment.get(entry.firstStaticSegment);
122
+ if (bucket)
123
+ bucket.push(entry);
124
+ else
125
+ staticByFirstSegment.set(entry.firstStaticSegment, [entry]);
126
+ if (entry.children.length > 0) {
127
+ staticParentRoutesWithChildren.push(entry);
128
+ }
129
+ continue;
130
+ }
131
+ genericRoutes.push(entry);
132
+ }
133
+ return { staticByFirstSegment, genericRoutes, staticParentRoutesWithChildren };
134
+ }
95
135
  function parseDynamicSegment(segment) {
96
136
  if (!segment.startsWith(':'))
97
137
  return null;
@@ -196,6 +236,7 @@ export function compileRoutes(routes, parentPath = '') {
196
236
  return sortRoutes(routes).map(route => {
197
237
  const fullPath = joinPaths(parentPath, route.path);
198
238
  const normalizedFull = normalizePath(fullPath);
239
+ const staticPath = isFullyStaticPath(fullPath);
199
240
  const exact = parsePath(fullPath);
200
241
  const prefix = normalizedFull === '/'
201
242
  ? null
@@ -203,6 +244,10 @@ export function compileRoutes(routes, parentPath = '') {
203
244
  return {
204
245
  route,
205
246
  fullPath,
247
+ normalizedFullPath: normalizedFull,
248
+ firstStaticSegment: resolveFirstStaticSegment(fullPath),
249
+ staticExactPath: staticPath ? normalizedFull : null,
250
+ staticPrefixPath: staticPath && normalizedFull !== '/' ? normalizedFull : null,
206
251
  exactPattern: exact.pattern,
207
252
  prefixPattern: prefix?.pattern ?? null,
208
253
  paramCaptures: exact.paramCaptures,
@@ -214,6 +259,9 @@ export function compileRoutes(routes, parentPath = '') {
214
259
  }
215
260
  /** Test a pathname against a compiled route's exact pattern. */
216
261
  function matchCompiled(pathname, compiled) {
262
+ if (compiled.staticExactPath !== null) {
263
+ return pathname === compiled.staticExactPath ? {} : null;
264
+ }
217
265
  const match = pathname.match(compiled.exactPattern);
218
266
  if (!match)
219
267
  return null;
@@ -223,6 +271,12 @@ function matchCompiled(pathname, compiled) {
223
271
  function matchCompiledPrefix(pathname, compiled) {
224
272
  if (!compiled.prefixPattern)
225
273
  return {};
274
+ if (compiled.staticPrefixPath !== null) {
275
+ if (pathname === compiled.staticPrefixPath || pathname.startsWith(`${compiled.staticPrefixPath}/`)) {
276
+ return {};
277
+ }
278
+ return null;
279
+ }
226
280
  const match = pathname.match(compiled.prefixPattern);
227
281
  if (!match)
228
282
  return null;
@@ -236,11 +290,17 @@ function matchCompiledPrefix(pathname, compiled) {
236
290
  */
237
291
  export function findCompiledRouteStackResult(pathname, compiled, stack = []) {
238
292
  const normalizedPathname = normalizePath(pathname);
239
- return findCompiledRouteStackResultNormalized(normalizedPathname, compiled, stack);
293
+ const firstPathSegment = normalizedPathname.split('/').filter(Boolean)[0] ?? '';
294
+ return findCompiledRouteStackResultNormalized(normalizedPathname, firstPathSegment, compiled, stack);
240
295
  }
241
- function findCompiledRouteStackResultNormalized(pathname, compiled, stack) {
296
+ function findCompiledRouteStackResultNormalized(pathname, firstPathSegment, compiled, stack) {
297
+ let levelIndex = routeLevelIndexCache.get(compiled);
298
+ if (!levelIndex) {
299
+ levelIndex = buildRouteLevelIndex(compiled);
300
+ routeLevelIndexCache.set(compiled, levelIndex);
301
+ }
242
302
  let bestPartial = null;
243
- for (const entry of compiled) {
303
+ const tryCandidate = (entry) => {
244
304
  const exactParams = matchCompiled(pathname, entry);
245
305
  const prefixParams = !exactParams && (entry.route.children || entry.route.layout)
246
306
  ? matchCompiledPrefix(pathname, entry)
@@ -253,29 +313,34 @@ function findCompiledRouteStackResultNormalized(pathname, compiled, stack) {
253
313
  path: entry.fullPath,
254
314
  params
255
315
  };
256
- const newStack = [...stack, match];
316
+ stack.push(match);
257
317
  if (entry.children.length > 0) {
258
- const childResult = findCompiledRouteStackResultNormalized(pathname, entry.children, newStack);
318
+ const childResult = findCompiledRouteStackResultNormalized(pathname, firstPathSegment, entry.children, stack);
259
319
  if (childResult) {
260
- if (childResult.exact)
320
+ if (childResult.exact) {
321
+ stack.pop();
261
322
  return childResult;
323
+ }
262
324
  if (!bestPartial || childResult.stack.length > bestPartial.length) {
263
325
  bestPartial = childResult.stack;
264
326
  }
265
327
  }
266
328
  }
267
329
  if (isExact && (entry.route.view || entry.route.redirect)) {
268
- return { stack: newStack, exact: true };
330
+ const exactStack = stack.slice();
331
+ stack.pop();
332
+ return { stack: exactStack, exact: true };
269
333
  }
270
334
  if (!isExact && (entry.route.layout || entry.route.children)) {
271
- if (!bestPartial || newStack.length > bestPartial.length) {
272
- bestPartial = newStack;
335
+ if (!bestPartial || stack.length > bestPartial.length) {
336
+ bestPartial = stack.slice();
273
337
  }
274
338
  }
339
+ stack.pop();
275
340
  }
276
341
  else if (entry.children.length > 0) {
277
342
  // Parent didn't match but children may have absolute-like paths
278
- const childResult = findCompiledRouteStackResultNormalized(pathname, entry.children, stack);
343
+ const childResult = findCompiledRouteStackResultNormalized(pathname, firstPathSegment, entry.children, stack);
279
344
  if (childResult) {
280
345
  if (childResult.exact)
281
346
  return childResult;
@@ -284,6 +349,29 @@ function findCompiledRouteStackResultNormalized(pathname, compiled, stack) {
284
349
  }
285
350
  }
286
351
  }
352
+ return null;
353
+ };
354
+ const specific = levelIndex.staticByFirstSegment.get(firstPathSegment);
355
+ if (specific) {
356
+ for (const entry of specific) {
357
+ const exact = tryCandidate(entry);
358
+ if (exact)
359
+ return exact;
360
+ }
361
+ }
362
+ for (const entry of levelIndex.genericRoutes) {
363
+ const exact = tryCandidate(entry);
364
+ if (exact)
365
+ return exact;
366
+ }
367
+ // Preserve support for absolute-like child paths under static parents.
368
+ // Example: parent "/admin" with child "/login" must still resolve "/login".
369
+ for (const entry of levelIndex.staticParentRoutesWithChildren) {
370
+ if (entry.firstStaticSegment === firstPathSegment)
371
+ continue;
372
+ const exact = tryCandidate(entry);
373
+ if (exact)
374
+ return exact;
287
375
  }
288
376
  return bestPartial ? { stack: bestPartial, exact: false } : null;
289
377
  }
@@ -148,17 +148,42 @@ export function createRouter(config) {
148
148
  function applyBase(fullPath) {
149
149
  if (!basePrefix)
150
150
  return fullPath;
151
- const url = new URL(fullPath, window.location.origin);
152
- const pathname = url.pathname === '/' ? basePrefix : normalizePath(`${basePrefix}${url.pathname}`);
153
- return `${pathname}${url.search}${url.hash}`;
151
+ const location = parseLocation(fullPath);
152
+ const pathname = location.pathname === '/' ? basePrefix : normalizePath(`${basePrefix}${location.pathname}`);
153
+ return `${pathname}${location.queryString ? `?${location.queryString}` : ''}${location.hash ? `#${location.hash}` : ''}`;
154
+ }
155
+ function parseRelativePath(to) {
156
+ let rest = to;
157
+ let hash = '';
158
+ const hashIdx = rest.indexOf('#');
159
+ if (hashIdx >= 0) {
160
+ hash = rest.slice(hashIdx + 1);
161
+ rest = rest.slice(0, hashIdx);
162
+ }
163
+ let queryString = '';
164
+ const queryIdx = rest.indexOf('?');
165
+ if (queryIdx >= 0) {
166
+ queryString = rest.slice(queryIdx + 1);
167
+ rest = rest.slice(0, queryIdx);
168
+ }
169
+ const pathname = stripBase(rest || '/');
170
+ const fullPath = `${pathname}${queryString ? `?${queryString}` : ''}${hash ? `#${hash}` : ''}`;
171
+ return { pathname, queryString, hash, fullPath };
172
+ }
173
+ function getLocationQuery(location) {
174
+ if (!location.query) {
175
+ location.query = new URLSearchParams(location.queryString);
176
+ }
177
+ return location.query;
154
178
  }
155
179
  function parseLocation(to) {
180
+ // Fast path for most app navigations (absolute pathname within same origin).
181
+ if (to.startsWith('/')) {
182
+ return parseRelativePath(to);
183
+ }
184
+ // Fallback for relative paths and absolute URLs.
156
185
  const url = new URL(to, window.location.href);
157
- const pathname = stripBase(url.pathname);
158
- const query = new URLSearchParams(url.search);
159
- const hash = url.hash ? url.hash.slice(1) : '';
160
- const fullPath = `${pathname}${url.search}${hash ? `#${hash}` : ''}`;
161
- return { pathname, query, hash, fullPath };
186
+ return parseRelativePath(`${url.pathname}${url.search}${url.hash}`);
162
187
  }
163
188
  function joinRoutePaths(parent, child) {
164
189
  if (!child || child === '.')
@@ -194,7 +219,7 @@ export function createRouter(config) {
194
219
  path: location.pathname,
195
220
  fullPath: location.fullPath,
196
221
  params: match.params,
197
- queryString: location.query.toString(),
222
+ queryString: location.queryString,
198
223
  hash: location.hash
199
224
  });
200
225
  }
@@ -223,7 +248,7 @@ export function createRouter(config) {
223
248
  }
224
249
  function resolvePreloadKey(match, location) {
225
250
  const routeId = resolvePreloadRouteId(match);
226
- const search = location.query.toString();
251
+ const search = location.queryString;
227
252
  const urlKey = search ? `${location.pathname}?${search}` : location.pathname;
228
253
  return `${routeId}::${match.path}::${urlKey}`;
229
254
  }
@@ -495,7 +520,7 @@ export function createRouter(config) {
495
520
  routePath: match.path,
496
521
  routeId: manifest?.id,
497
522
  params: { ...match.params },
498
- queryString: location.query.toString(),
523
+ queryString: location.queryString,
499
524
  tags: resolveMatchTags(match),
500
525
  score: resolveMatchScore(match)
501
526
  };
@@ -656,7 +681,7 @@ export function createRouter(config) {
656
681
  path: location.pathname,
657
682
  fullPath: location.fullPath,
658
683
  params: {},
659
- queryString: location.query.toString(),
684
+ queryString: location.queryString,
660
685
  hash: location.hash
661
686
  });
662
687
  statusSignal.set({ state: 'loading', to: toState });
@@ -941,7 +966,7 @@ export function createRouter(config) {
941
966
  // Validate all matches first, then load data in parallel
942
967
  let dataStack;
943
968
  try {
944
- const queryValues = createRouteQueryValues(location.query);
969
+ const queryValues = createRouteQueryValues(getLocationQuery(location));
945
970
  // Phase 1: Validate (sequential -- fail fast on first error)
946
971
  for (const match of matchStack) {
947
972
  const paramsValues = createRouteParamsValues(match.params);
@@ -1394,7 +1419,7 @@ export function createRouter(config) {
1394
1419
  }
1395
1420
  }
1396
1421
  }
1397
- const queryValues = createRouteQueryValues(location.query);
1422
+ const queryValues = createRouteQueryValues(getLocationQuery(location));
1398
1423
  const pending = [];
1399
1424
  for (const match of stack) {
1400
1425
  const preloadFn = match.route.preload ?? match.route.loader;