dalila 1.9.8 → 1.9.9

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
@@ -10,6 +10,8 @@ import { effect, createScope, withScope, isInDevMode, signal, computeVirtualRang
10
10
  import { WRAPPED_HANDLER } from '../form/form.js';
11
11
  import { linkScopeToDom, withDevtoolsDomTarget } from '../core/devtools.js';
12
12
  import { isComponent, normalizePropDef, coercePropValue, kebabToCamel, camelToKebab } from './component.js';
13
+ import { observeLazyElement, getLazyComponent } from './lazy.js';
14
+ import { bindBoundary } from './boundary.js';
13
15
  // ============================================================================
14
16
  // Utilities
15
17
  // ============================================================================
@@ -1355,6 +1357,161 @@ function bindPortal(root, ctx, cleanups) {
1355
1357
  }
1356
1358
  }
1357
1359
  // ============================================================================
1360
+ // d-lazy Directive
1361
+ // ============================================================================
1362
+ /**
1363
+ * Bind all [d-lazy] directives within root.
1364
+ * Loads a lazy component when it enters the viewport.
1365
+ */
1366
+ function bindLazy(root, ctx, cleanups, refs, events) {
1367
+ const elements = qsaIncludingRoot(root, '[d-lazy]');
1368
+ for (const el of elements) {
1369
+ const lazyComponentName = normalizeBinding(el.getAttribute('d-lazy'));
1370
+ if (!lazyComponentName)
1371
+ continue;
1372
+ const lazyResult = getLazyComponent(lazyComponentName);
1373
+ if (!lazyResult) {
1374
+ warn(`d-lazy: component "${lazyComponentName}" not found. Use createLazyComponent() to create it.`);
1375
+ continue;
1376
+ }
1377
+ const { state } = lazyResult;
1378
+ const htmlEl = el;
1379
+ // Get loading and error templates from attributes
1380
+ const loadingTemplate = el.getAttribute('d-lazy-loading') ?? state.loadingTemplate ?? '';
1381
+ const errorTemplate = el.getAttribute('d-lazy-error') ?? state.errorTemplate ?? '';
1382
+ // Remove the d-lazy attribute to prevent reprocessing
1383
+ el.removeAttribute('d-lazy');
1384
+ el.removeAttribute('d-lazy-loading');
1385
+ el.removeAttribute('d-lazy-error');
1386
+ // Track the current rendered node (starts as the original placeholder element)
1387
+ let currentNode = htmlEl;
1388
+ let componentMounted = false;
1389
+ let componentDispose = null;
1390
+ let componentEl = null;
1391
+ let hasIntersected = false;
1392
+ const refName = normalizeBinding(htmlEl.getAttribute('d-ref'));
1393
+ const syncRef = (node) => {
1394
+ if (!refName)
1395
+ return;
1396
+ if (node instanceof Element) {
1397
+ refs.set(refName, node);
1398
+ }
1399
+ };
1400
+ const replaceCurrentNode = (nextNode) => {
1401
+ const parent = currentNode.parentNode;
1402
+ if (!parent)
1403
+ return;
1404
+ parent.replaceChild(nextNode, currentNode);
1405
+ currentNode = nextNode;
1406
+ syncRef(nextNode);
1407
+ };
1408
+ const unmountComponent = () => {
1409
+ if (componentDispose) {
1410
+ componentDispose();
1411
+ componentDispose = null;
1412
+ }
1413
+ componentMounted = false;
1414
+ componentEl = null;
1415
+ };
1416
+ // Function to render the loaded component
1417
+ const renderComponent = () => {
1418
+ const comp = state.component();
1419
+ if (!comp)
1420
+ return;
1421
+ // Create component element
1422
+ const compDef = comp.definition;
1423
+ const compEl = document.createElement(compDef.tag);
1424
+ // Copy attributes from placeholder to component
1425
+ for (const attr of Array.from(htmlEl.attributes)) {
1426
+ if (!attr.name.startsWith('d-')) {
1427
+ compEl.setAttribute(attr.name, attr.value);
1428
+ }
1429
+ }
1430
+ if (componentMounted && componentEl === compEl)
1431
+ return;
1432
+ replaceCurrentNode(compEl);
1433
+ componentEl = compEl;
1434
+ // Bind the component
1435
+ const parentCtx = Object.create(ctx);
1436
+ const parent = compEl.parentNode;
1437
+ const nextSibling = compEl.nextSibling;
1438
+ componentDispose = bind(compEl, parentCtx, {
1439
+ components: { [compDef.tag]: comp },
1440
+ events,
1441
+ _skipLifecycle: true,
1442
+ });
1443
+ // bind() may replace the component host node; keep currentNode/ref pointing to the connected node.
1444
+ if (!compEl.isConnected && parent) {
1445
+ const renderedNode = nextSibling ? nextSibling.previousSibling : parent.lastChild;
1446
+ if (renderedNode instanceof Node) {
1447
+ currentNode = renderedNode;
1448
+ syncRef(renderedNode);
1449
+ }
1450
+ }
1451
+ componentMounted = true;
1452
+ };
1453
+ // Function to show loading state
1454
+ const showLoading = () => {
1455
+ if (loadingTemplate) {
1456
+ if (componentMounted) {
1457
+ unmountComponent();
1458
+ }
1459
+ const loadingEl = document.createElement('div');
1460
+ loadingEl.innerHTML = loadingTemplate;
1461
+ replaceCurrentNode(loadingEl);
1462
+ }
1463
+ };
1464
+ // Function to show error state
1465
+ const showError = (err) => {
1466
+ if (componentMounted) {
1467
+ unmountComponent();
1468
+ }
1469
+ if (errorTemplate) {
1470
+ const errorEl = document.createElement('div');
1471
+ errorEl.innerHTML = errorTemplate;
1472
+ replaceCurrentNode(errorEl);
1473
+ }
1474
+ else {
1475
+ warn(`d-lazy: failed to load "${lazyComponentName}": ${err.message}`);
1476
+ }
1477
+ };
1478
+ const syncFromState = () => {
1479
+ // Always read reactive state so this effect stays subscribed even before visibility.
1480
+ const loading = state.loading();
1481
+ const error = state.error();
1482
+ const comp = state.component();
1483
+ if (!hasIntersected)
1484
+ return;
1485
+ if (error) {
1486
+ showError(error);
1487
+ return;
1488
+ }
1489
+ if (loading && !comp) {
1490
+ showLoading();
1491
+ return;
1492
+ }
1493
+ if (comp && !componentMounted) {
1494
+ renderComponent();
1495
+ }
1496
+ };
1497
+ // React to loading state changes
1498
+ bindEffect(htmlEl, () => {
1499
+ syncFromState();
1500
+ });
1501
+ // Observe element for viewport visibility
1502
+ const cleanupObserver = observeLazyElement(htmlEl, () => {
1503
+ hasIntersected = true;
1504
+ syncFromState();
1505
+ state.load();
1506
+ }, 0 // Trigger when element enters viewport
1507
+ );
1508
+ cleanups.push(() => {
1509
+ cleanupObserver();
1510
+ unmountComponent();
1511
+ });
1512
+ }
1513
+ }
1514
+ // ============================================================================
1358
1515
  // d-when Directive
1359
1516
  // ============================================================================
1360
1517
  /**
@@ -3927,11 +4084,14 @@ export function bind(root, ctx, options = {}) {
3927
4084
  bindVirtualEach(root, ctx, cleanups);
3928
4085
  // 4. d-each — must run early: removes templates before TreeWalker visits them
3929
4086
  bindEach(root, ctx, cleanups);
3930
- // 5. Components — must run after d-each but before d-ref / text interpolation
4087
+ // 5. d-boundary — must run before child directive/component passes
4088
+ // to avoid binding boundary children twice (original + cloned subtree).
4089
+ bindBoundary(root, ctx, cleanups);
4090
+ // 6. Components — must run after d-each but before d-ref / text interpolation
3931
4091
  bindComponents(root, ctx, events, cleanups, onMountError);
3932
- // 6. d-ref — collect element references (after d-each removes templates)
4092
+ // 7. d-ref — collect element references (after d-each removes templates)
3933
4093
  bindRef(root, refs);
3934
- // 6.5. d-text — safe textContent binding (before text interpolation)
4094
+ // 7.5. d-text — safe textContent binding (before text interpolation)
3935
4095
  bindText(root, ctx, cleanups);
3936
4096
  // 7. Text interpolation (template plan cache + lazy parser fallback)
3937
4097
  bindTextInterpolation(root, ctx, rawTextSelectors, templatePlanCacheConfig, benchSession);
@@ -3955,6 +4115,8 @@ export function bind(root, ctx, options = {}) {
3955
4115
  bindError(root, ctx, cleanups);
3956
4116
  bindFormError(root, ctx, cleanups);
3957
4117
  // 17. d-portal — move already-bound elements to external targets
4118
+ // d-lazy directive - loads component when it enters viewport
4119
+ bindLazy(root, ctx, cleanups, refs, events);
3958
4120
  bindPortal(root, ctx, cleanups);
3959
4121
  // 18. d-if — must run last: elements are fully bound before conditional removal
3960
4122
  bindIf(root, ctx, cleanups, transitionRegistry);
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Dalila Error Boundary
3
+ *
4
+ * Provides error boundary component that captures errors in children
5
+ * and displays a fallback template.
6
+ *
7
+ * @module dalila/runtime/boundary
8
+ */
9
+ import { signal, Signal } from '../core/index.js';
10
+ import type { Component } from './component.js';
11
+ import type { BindContext, DisposeFunction } from './bind.js';
12
+ export interface ErrorBoundaryOptions {
13
+ /** Template to show when an error occurs */
14
+ fallback: string;
15
+ /** Callback when error is caught */
16
+ onError?: (error: Error) => void;
17
+ /** Callback when error is reset */
18
+ onReset?: () => void;
19
+ }
20
+ export interface ErrorBoundaryState {
21
+ /** The error that was caught */
22
+ error: ReturnType<typeof signal<Error | null>>;
23
+ /** Function to reset the error and retry */
24
+ reset: () => void;
25
+ /** Whether there's an error */
26
+ hasError: () => boolean;
27
+ }
28
+ export type ErrorBoundaryResult = {
29
+ /** The error boundary component */
30
+ component: Component;
31
+ /** Access to error boundary state */
32
+ state: ErrorBoundaryState;
33
+ };
34
+ /**
35
+ * Creates an error boundary component that catches errors in its children.
36
+ *
37
+ * @param options - Configuration for the error boundary
38
+ * @returns Component with error boundary functionality
39
+ *
40
+ * @example
41
+ * ```ts
42
+ * const ErrorBoundary = createErrorBoundary({
43
+ * fallback: '<div class="error">Something went wrong</div>',
44
+ * onError: (err) => console.error(err),
45
+ * });
46
+ *
47
+ * // Use as component
48
+ * <error-boundary>
49
+ * <MyComponent />
50
+ * </error-boundary>
51
+ * ```
52
+ */
53
+ export declare function createErrorBoundary(options: ErrorBoundaryOptions): Component;
54
+ /**
55
+ * Bind d-boundary directive - wraps children with error handling
56
+ * This is typically used inside a component that provides error state
57
+ */
58
+ export declare function bindBoundary(root: Element, ctx: BindContext, cleanups: DisposeFunction[]): void;
59
+ /**
60
+ * Wraps a function with error boundary logic
61
+ *
62
+ * @param fn - Function that might throw
63
+ * @param errorSignal - Signal to store the error
64
+ * @returns Result of the function or undefined if error
65
+ *
66
+ * @example
67
+ * ```ts
68
+ * const result = withErrorBoundary(
69
+ * () => riskyOperation(),
70
+ * errorSignal
71
+ * );
72
+ * ```
73
+ */
74
+ export declare function withErrorBoundary<T>(fn: () => T, errorSignal: Signal<Error | null>): T | undefined;
75
+ /**
76
+ * Creates error boundary state for use in component setup
77
+ *
78
+ * @param options - Configuration options
79
+ * @returns Error boundary state and methods
80
+ *
81
+ * @example
82
+ * ```ts
83
+ * const MyComponent = defineComponent({
84
+ * tag: 'my-component',
85
+ * setup(props, ctx) {
86
+ * const { error, reset, hasError } = createErrorBoundaryState({
87
+ * onError: (err) => logError(err),
88
+ * });
89
+ *
90
+ * const handleClick = () => {
91
+ * withErrorBoundary(() => {
92
+ * // risky operation
93
+ * }, error);
94
+ * };
95
+ *
96
+ * return { error, handleClick };
97
+ * }
98
+ * });
99
+ * ```
100
+ */
101
+ export declare function createErrorBoundaryState(options?: {
102
+ onError?: (error: Error) => void;
103
+ onReset?: () => void;
104
+ }): ErrorBoundaryState;
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Dalila Error Boundary
3
+ *
4
+ * Provides error boundary component that captures errors in children
5
+ * and displays a fallback template.
6
+ *
7
+ * @module dalila/runtime/boundary
8
+ */
9
+ import { effect, signal } from '../core/index.js';
10
+ import { bind } from './bind.js';
11
+ import { defineComponent } from './component.js';
12
+ // ============================================================================
13
+ // Error Boundary Component
14
+ // ============================================================================
15
+ /**
16
+ * Creates an error boundary component that catches errors in its children.
17
+ *
18
+ * @param options - Configuration for the error boundary
19
+ * @returns Component with error boundary functionality
20
+ *
21
+ * @example
22
+ * ```ts
23
+ * const ErrorBoundary = createErrorBoundary({
24
+ * fallback: '<div class="error">Something went wrong</div>',
25
+ * onError: (err) => console.error(err),
26
+ * });
27
+ *
28
+ * // Use as component
29
+ * <error-boundary>
30
+ * <MyComponent />
31
+ * </error-boundary>
32
+ * ```
33
+ */
34
+ export function createErrorBoundary(options) {
35
+ const { fallback: fallbackTemplate = '<div>Error occurred</div>', onError, onReset, } = options;
36
+ // Create signal for tracking error state
37
+ const errorSignal = signal(null);
38
+ const hasError = () => {
39
+ return errorSignal() !== null;
40
+ };
41
+ const reset = () => {
42
+ errorSignal.set(null);
43
+ onReset?.();
44
+ };
45
+ const escapedFallback = escapeAttribute(fallbackTemplate);
46
+ // Create the error boundary component
47
+ const tag = `error-boundary-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
48
+ const boundaryComponent = defineComponent({
49
+ tag,
50
+ template: `<div d-boundary="${escapedFallback}" d-boundary-error="$$boundaryError" d-boundary-reset="$$boundaryReset"><slot></slot></div>`,
51
+ props: {},
52
+ setup: () => {
53
+ if (onError) {
54
+ effect(() => {
55
+ const error = errorSignal();
56
+ if (error)
57
+ onError(error);
58
+ });
59
+ }
60
+ // Return error state and reset function
61
+ return {
62
+ $$boundaryError: errorSignal,
63
+ $$boundaryReset: reset,
64
+ $$boundaryHasError: hasError,
65
+ };
66
+ }
67
+ });
68
+ // Return component with state
69
+ const result = {
70
+ component: boundaryComponent,
71
+ state: {
72
+ error: errorSignal,
73
+ reset,
74
+ hasError,
75
+ }
76
+ };
77
+ return boundaryComponent;
78
+ }
79
+ // ============================================================================
80
+ // d-boundary Directive (for use within existing bind context)
81
+ // ============================================================================
82
+ /**
83
+ * Bind d-boundary directive - wraps children with error handling
84
+ * This is typically used inside a component that provides error state
85
+ */
86
+ export function bindBoundary(root, ctx, cleanups) {
87
+ const elements = qsaIncludingRoot(root, '[d-boundary]');
88
+ const boundary = root.closest('[data-dalila-internal-bound]');
89
+ const consumedNested = new WeakSet();
90
+ for (const el of elements) {
91
+ if (consumedNested.has(el))
92
+ continue;
93
+ // Skip stale nodes from the initial snapshot.
94
+ if (!root.contains(el))
95
+ continue;
96
+ if (el.closest('[data-dalila-internal-bound]') !== boundary)
97
+ continue;
98
+ for (const nested of Array.from(el.querySelectorAll('[d-boundary]'))) {
99
+ consumedNested.add(nested);
100
+ }
101
+ const fallbackTemplate = el.getAttribute('d-boundary')?.trim() || '<div>Error occurred</div>';
102
+ const errorBindingName = normalizeBinding(el.getAttribute('d-boundary-error'));
103
+ const resetBindingName = normalizeBinding(el.getAttribute('d-boundary-reset'));
104
+ // Get error and reset from context
105
+ const errorSignal = errorBindingName ? ctx[errorBindingName] : null;
106
+ const resetFn = resetBindingName ? ctx[resetBindingName] : null;
107
+ // Remove d-boundary attributes to prevent reprocessing
108
+ el.removeAttribute('d-boundary');
109
+ el.removeAttribute('d-boundary-error');
110
+ el.removeAttribute('d-boundary-reset');
111
+ const templateChildren = Array.from(el.childNodes).map((child) => child.cloneNode(true));
112
+ const host = el;
113
+ // Preserve host node and render boundary content inside it.
114
+ while (host.firstChild) {
115
+ host.removeChild(host.firstChild);
116
+ }
117
+ let mountedNode = null;
118
+ let mountedDispose = null;
119
+ const unmountCurrent = () => {
120
+ if (mountedDispose) {
121
+ mountedDispose();
122
+ mountedDispose = null;
123
+ }
124
+ if (mountedNode && mountedNode.parentNode) {
125
+ mountedNode.parentNode.removeChild(mountedNode);
126
+ }
127
+ mountedNode = null;
128
+ };
129
+ const mountChildren = () => {
130
+ unmountCurrent();
131
+ const childCtx = Object.create(ctx);
132
+ if (errorSignal)
133
+ childCtx.error = errorSignal;
134
+ if (resetFn)
135
+ childCtx.reset = resetFn;
136
+ const container = document.createElement('div');
137
+ container.setAttribute('data-boundary-children', '');
138
+ container.setAttribute('data-dalila-internal-bound', '');
139
+ for (const child of templateChildren) {
140
+ container.appendChild(child.cloneNode(true));
141
+ }
142
+ host.appendChild(container);
143
+ mountedDispose = bind(container, childCtx, {
144
+ _skipLifecycle: true,
145
+ });
146
+ mountedNode = container;
147
+ };
148
+ const mountError = (error) => {
149
+ unmountCurrent();
150
+ const errorCtx = Object.create(ctx);
151
+ if (errorSignal)
152
+ errorCtx.error = errorSignal;
153
+ if (resetFn)
154
+ errorCtx.reset = resetFn;
155
+ const errorDisplay = document.createElement('div');
156
+ errorDisplay.setAttribute('data-boundary-error', '');
157
+ errorDisplay.setAttribute('data-dalila-internal-bound', '');
158
+ errorDisplay.innerHTML = fallbackTemplate;
159
+ const errorMsg = errorDisplay.querySelector('[data-error-message]');
160
+ if (errorMsg) {
161
+ errorMsg.textContent = error.message;
162
+ }
163
+ host.appendChild(errorDisplay);
164
+ mountedDispose = bind(errorDisplay, errorCtx, {
165
+ _skipLifecycle: true,
166
+ });
167
+ mountedNode = errorDisplay;
168
+ };
169
+ if (errorSignal) {
170
+ const getCurrentError = () => (typeof errorSignal === 'function' ? errorSignal() : null);
171
+ let lastError = getCurrentError();
172
+ if (lastError) {
173
+ mountError(lastError);
174
+ }
175
+ else {
176
+ mountChildren();
177
+ }
178
+ const disposeEffect = effect(() => {
179
+ const error = getCurrentError();
180
+ if (error === lastError)
181
+ return;
182
+ lastError = error;
183
+ if (error) {
184
+ mountError(error);
185
+ }
186
+ else {
187
+ mountChildren();
188
+ }
189
+ });
190
+ cleanups.push(disposeEffect);
191
+ }
192
+ else {
193
+ mountChildren();
194
+ }
195
+ cleanups.push(() => {
196
+ unmountCurrent();
197
+ });
198
+ }
199
+ }
200
+ // Helper to find elements including root
201
+ function qsaIncludingRoot(root, selector) {
202
+ const out = [];
203
+ if (root.matches(selector))
204
+ out.push(root);
205
+ out.push(...Array.from(root.querySelectorAll(selector)));
206
+ return out;
207
+ }
208
+ // Helper to normalize binding
209
+ function normalizeBinding(raw) {
210
+ if (!raw)
211
+ return null;
212
+ const trimmed = raw.trim();
213
+ if (!trimmed)
214
+ return null;
215
+ return trimmed;
216
+ }
217
+ function escapeAttribute(value) {
218
+ return value
219
+ .replace(/&/g, '&amp;')
220
+ .replace(/"/g, '&quot;')
221
+ .replace(/</g, '&lt;')
222
+ .replace(/>/g, '&gt;');
223
+ }
224
+ // ============================================================================
225
+ // withErrorBoundary Helper
226
+ // ============================================================================
227
+ /**
228
+ * Wraps a function with error boundary logic
229
+ *
230
+ * @param fn - Function that might throw
231
+ * @param errorSignal - Signal to store the error
232
+ * @returns Result of the function or undefined if error
233
+ *
234
+ * @example
235
+ * ```ts
236
+ * const result = withErrorBoundary(
237
+ * () => riskyOperation(),
238
+ * errorSignal
239
+ * );
240
+ * ```
241
+ */
242
+ export function withErrorBoundary(fn, errorSignal) {
243
+ try {
244
+ return fn();
245
+ }
246
+ catch (error) {
247
+ errorSignal.set(error instanceof Error ? error : new Error(String(error)));
248
+ return undefined;
249
+ }
250
+ }
251
+ // ============================================================================
252
+ // createErrorBoundaryState (for use in setup)
253
+ // ============================================================================
254
+ /**
255
+ * Creates error boundary state for use in component setup
256
+ *
257
+ * @param options - Configuration options
258
+ * @returns Error boundary state and methods
259
+ *
260
+ * @example
261
+ * ```ts
262
+ * const MyComponent = defineComponent({
263
+ * tag: 'my-component',
264
+ * setup(props, ctx) {
265
+ * const { error, reset, hasError } = createErrorBoundaryState({
266
+ * onError: (err) => logError(err),
267
+ * });
268
+ *
269
+ * const handleClick = () => {
270
+ * withErrorBoundary(() => {
271
+ * // risky operation
272
+ * }, error);
273
+ * };
274
+ *
275
+ * return { error, handleClick };
276
+ * }
277
+ * });
278
+ * ```
279
+ */
280
+ export function createErrorBoundaryState(options) {
281
+ const errorSignal = signal(null);
282
+ const hasError = () => {
283
+ return errorSignal() !== null;
284
+ };
285
+ const reset = () => {
286
+ errorSignal.set(null);
287
+ options?.onReset?.();
288
+ };
289
+ // Set up error handler
290
+ const handleError = options?.onError;
291
+ if (handleError) {
292
+ effect(() => {
293
+ const error = errorSignal();
294
+ if (error) {
295
+ handleError(error);
296
+ }
297
+ });
298
+ }
299
+ return {
300
+ error: errorSignal,
301
+ reset,
302
+ hasError,
303
+ };
304
+ }
@@ -12,3 +12,7 @@ export { fromHtml } from './fromHtml.js';
12
12
  export type { FromHtmlOptions } from './fromHtml.js';
13
13
  export { defineComponent } from './component.js';
14
14
  export type { Component, ComponentDefinition, PropsSchema, PropSignals, SetupContext, TypedPropSignals, TypedSetupContext, EmitsSchema, RefsSchema, TypedEmit, TypedRef, } from './component.js';
15
+ export { createLazyComponent, createSuspense, getLazyComponent, preloadLazyComponent, isLazyComponentLoaded, getLazyComponentState, observeLazyElement, } from './lazy.js';
16
+ export type { LazyComponentOptions, LazyComponentState, LazyComponentLoader, LazyComponentResult, } from './lazy.js';
17
+ export { createErrorBoundary, bindBoundary, withErrorBoundary, createErrorBoundaryState, } from './boundary.js';
18
+ export type { ErrorBoundaryOptions, ErrorBoundaryState, ErrorBoundaryResult, } from './boundary.js';
@@ -9,3 +9,5 @@
9
9
  export { bind, autoBind, mount, configure, createPortalTarget, getVirtualListController, scrollToVirtualIndex } from './bind.js';
10
10
  export { fromHtml } from './fromHtml.js';
11
11
  export { defineComponent } from './component.js';
12
+ export { createLazyComponent, createSuspense, getLazyComponent, preloadLazyComponent, isLazyComponentLoaded, getLazyComponentState, observeLazyElement, } from './lazy.js';
13
+ export { createErrorBoundary, bindBoundary, withErrorBoundary, createErrorBoundaryState, } from './boundary.js';
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Dalila Lazy Components
3
+ *
4
+ * Provides lazy loading for components with:
5
+ * - createLazyComponent: Create a component that loads on demand
6
+ * - d-lazy directive: Load component when it enters viewport
7
+ * - d-suspense: Show fallback while component is loading
8
+ *
9
+ * @module dalila/runtime/lazy
10
+ */
11
+ import { signal } from '../core/index.js';
12
+ import type { Component } from './component.js';
13
+ export interface LazyComponentOptions {
14
+ /** Loading fallback template */
15
+ loading?: string;
16
+ /** Error template to show on failure */
17
+ error?: string;
18
+ /** Delay before showing loading state (ms) */
19
+ loadingDelay?: number;
20
+ }
21
+ export interface LazyComponentState {
22
+ /** Whether the component is currently loading */
23
+ loading: ReturnType<typeof signal<boolean>>;
24
+ /** Whether the component has failed to load */
25
+ error: ReturnType<typeof signal<Error | null>>;
26
+ /** The loaded component (if successful) */
27
+ component: ReturnType<typeof signal<Component | null>>;
28
+ /** Function to trigger loading */
29
+ load: () => void;
30
+ /** Function to retry after error */
31
+ retry: () => void;
32
+ /** Whether the component has been loaded */
33
+ loaded: ReturnType<typeof signal<boolean>>;
34
+ /** Default loading template (used by d-lazy when attribute is omitted) */
35
+ loadingTemplate: string;
36
+ /** Default error template (used by d-lazy when attribute is omitted) */
37
+ errorTemplate: string;
38
+ }
39
+ export type LazyComponentLoader = () => Promise<{
40
+ default: Component;
41
+ } | Component>;
42
+ export interface LazyComponentResult {
43
+ /** The lazy component definition */
44
+ component: Component;
45
+ /** Access to lazy loading state */
46
+ state: LazyComponentState;
47
+ }
48
+ /**
49
+ * Get a lazy component by its tag name
50
+ */
51
+ export declare function getLazyComponent(tag: string): LazyComponentResult | undefined;
52
+ /**
53
+ * Creates a lazy-loaded component that defers loading until needed.
54
+ *
55
+ * @param loader - Function that returns a promise resolving to the component
56
+ * @param options - Configuration options for the lazy component
57
+ * @returns Component definition with loading state
58
+ *
59
+ * @example
60
+ * ```ts
61
+ * const LazyModal = createLazyComponent(() => import('./Modal.svelte'));
62
+ *
63
+ * // With custom loading template
64
+ * const LazyModal = createLazyComponent(
65
+ * () => import('./Modal.svelte'),
66
+ * { loading: '<div>Loading...</div>' }
67
+ * );
68
+ * ```
69
+ */
70
+ export declare function createLazyComponent(loader: LazyComponentLoader, options?: LazyComponentOptions): Component;
71
+ /**
72
+ * Creates a suspense component that shows loading/ error states
73
+ * while child components are loading.
74
+ *
75
+ * @param options - Configuration for suspense behavior
76
+ * @returns Component that wraps children with suspense behavior
77
+ *
78
+ * @example
79
+ * ```html
80
+ * <d-suspense>
81
+ * <d-placeholder>Loading...</d-placeholder>
82
+ * <my-heavy-component></my-heavy-component>
83
+ * </d-suspense>
84
+ * ```
85
+ */
86
+ export declare function createSuspense(options?: {
87
+ /** Template to show while loading */
88
+ loading?: string;
89
+ /** Template to show on error */
90
+ error?: string;
91
+ /** Delay before showing loading state */
92
+ loadingDelay?: number;
93
+ }): Component;
94
+ /**
95
+ * Preload a lazy component
96
+ */
97
+ export declare function preloadLazyComponent(tag: string): void;
98
+ /**
99
+ * Check if a lazy component is loaded
100
+ */
101
+ export declare function isLazyComponentLoaded(tag: string): boolean;
102
+ /**
103
+ * Get lazy component loading state
104
+ */
105
+ export declare function getLazyComponentState(tag: string): LazyComponentState | undefined;
106
+ /**
107
+ * Observe an element for lazy loading when it enters viewport
108
+ */
109
+ export declare function observeLazyElement(el: Element, loadCallback: () => void, threshold?: number): () => void;
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Dalila Lazy Components
3
+ *
4
+ * Provides lazy loading for components with:
5
+ * - createLazyComponent: Create a component that loads on demand
6
+ * - d-lazy directive: Load component when it enters viewport
7
+ * - d-suspense: Show fallback while component is loading
8
+ *
9
+ * @module dalila/runtime/lazy
10
+ */
11
+ import { signal } from '../core/index.js';
12
+ import { defineComponent, isComponent } from './component.js';
13
+ // ============================================================================
14
+ // Lazy Component Registry
15
+ // ============================================================================
16
+ /**
17
+ * Global registry for lazy-loaded components
18
+ */
19
+ const lazyComponentRegistry = new Map();
20
+ /**
21
+ * Get a lazy component by its tag name
22
+ */
23
+ export function getLazyComponent(tag) {
24
+ return lazyComponentRegistry.get(tag);
25
+ }
26
+ // ============================================================================
27
+ // createLazyComponent
28
+ // ============================================================================
29
+ /**
30
+ * Creates a lazy-loaded component that defers loading until needed.
31
+ *
32
+ * @param loader - Function that returns a promise resolving to the component
33
+ * @param options - Configuration options for the lazy component
34
+ * @returns Component definition with loading state
35
+ *
36
+ * @example
37
+ * ```ts
38
+ * const LazyModal = createLazyComponent(() => import('./Modal.svelte'));
39
+ *
40
+ * // With custom loading template
41
+ * const LazyModal = createLazyComponent(
42
+ * () => import('./Modal.svelte'),
43
+ * { loading: '<div>Loading...</div>' }
44
+ * );
45
+ * ```
46
+ */
47
+ export function createLazyComponent(loader, options = {}) {
48
+ const { loadingDelay = 0, loading = '', error = '' } = options;
49
+ // Create signals for tracking load state
50
+ const loadingSignal = signal(false);
51
+ const errorSignal = signal(null);
52
+ const componentSignal = signal(null);
53
+ const loadedSignal = signal(false);
54
+ let loadingTimeout = null;
55
+ let loadInFlight = false;
56
+ const load = () => {
57
+ if (loadedSignal() || loadInFlight)
58
+ return;
59
+ loadInFlight = true;
60
+ // Handle loading delay
61
+ if (loadingDelay > 0) {
62
+ loadingTimeout = setTimeout(() => {
63
+ loadingSignal.set(true);
64
+ }, loadingDelay);
65
+ }
66
+ else {
67
+ loadingSignal.set(true);
68
+ }
69
+ errorSignal.set(null);
70
+ Promise.resolve()
71
+ .then(() => loader())
72
+ .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;
88
+ })
89
+ .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;
97
+ });
98
+ };
99
+ const retry = () => {
100
+ errorSignal.set(null);
101
+ load();
102
+ };
103
+ // Create the wrapper component
104
+ const tag = `lazy-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
105
+ const wrapperComponent = defineComponent({
106
+ tag,
107
+ template: '', // Required: empty template for lazy wrapper
108
+ props: {},
109
+ setup: () => {
110
+ // Auto-load when component is mounted
111
+ // The actual loading is triggered by the d-lazy directive or manually
112
+ return {
113
+ $$lazyLoading: loadingSignal,
114
+ $$lazyError: errorSignal,
115
+ $$lazyComponent: componentSignal,
116
+ $$lazyLoad: load,
117
+ $$lazyRetry: retry,
118
+ $$lazyLoaded: loadedSignal,
119
+ };
120
+ }
121
+ });
122
+ // Register in global registry for lookup by d-lazy directive
123
+ const result = {
124
+ component: wrapperComponent,
125
+ state: {
126
+ loading: loadingSignal,
127
+ error: errorSignal,
128
+ component: componentSignal,
129
+ load,
130
+ retry,
131
+ loaded: loadedSignal,
132
+ loadingTemplate: loading,
133
+ errorTemplate: error,
134
+ }
135
+ };
136
+ lazyComponentRegistry.set(tag, result);
137
+ return wrapperComponent;
138
+ }
139
+ // ============================================================================
140
+ // d-suspense Component
141
+ // ============================================================================
142
+ /**
143
+ * Creates a suspense component that shows loading/ error states
144
+ * while child components are loading.
145
+ *
146
+ * @param options - Configuration for suspense behavior
147
+ * @returns Component that wraps children with suspense behavior
148
+ *
149
+ * @example
150
+ * ```html
151
+ * <d-suspense>
152
+ * <d-placeholder>Loading...</d-placeholder>
153
+ * <my-heavy-component></my-heavy-component>
154
+ * </d-suspense>
155
+ * ```
156
+ */
157
+ export function createSuspense(options = {}) {
158
+ const suspenseTag = `d-suspense-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
159
+ return defineComponent({
160
+ tag: suspenseTag,
161
+ props: {},
162
+ // Keep children rendered; loading/error orchestration can be layered on top.
163
+ template: `<div data-suspense=""><slot></slot></div>`,
164
+ setup: () => {
165
+ // Reserved for future suspense orchestration (loading/error/loadingDelay).
166
+ void options;
167
+ return {};
168
+ }
169
+ });
170
+ }
171
+ // ============================================================================
172
+ // Lazy Loading Utilities
173
+ // ============================================================================
174
+ /**
175
+ * Preload a lazy component
176
+ */
177
+ export function preloadLazyComponent(tag) {
178
+ const lazyResult = lazyComponentRegistry.get(tag);
179
+ if (lazyResult) {
180
+ lazyResult.state.load();
181
+ }
182
+ }
183
+ /**
184
+ * Check if a lazy component is loaded
185
+ */
186
+ export function isLazyComponentLoaded(tag) {
187
+ const lazyResult = lazyComponentRegistry.get(tag);
188
+ return lazyResult?.state.loaded() ?? false;
189
+ }
190
+ /**
191
+ * Get lazy component loading state
192
+ */
193
+ export function getLazyComponentState(tag) {
194
+ return lazyComponentRegistry.get(tag)?.state;
195
+ }
196
+ // ============================================================================
197
+ // Intersection Observer for d-lazy
198
+ // ============================================================================
199
+ const lazyObserverElements = new WeakMap();
200
+ const lazyObservers = new Map();
201
+ function getLazyIntersectionObserver(threshold = 0) {
202
+ const currentCtor = globalThis.IntersectionObserver;
203
+ const cached = lazyObservers.get(threshold);
204
+ if (cached && cached.ctor === currentCtor) {
205
+ return cached.observer;
206
+ }
207
+ const observer = new IntersectionObserver((entries) => {
208
+ for (const entry of entries) {
209
+ if (entry.isIntersecting) {
210
+ const data = lazyObserverElements.get(entry.target);
211
+ if (data?.callback) {
212
+ data.callback();
213
+ }
214
+ // Stop observing after triggering
215
+ observer?.unobserve(entry.target);
216
+ }
217
+ }
218
+ }, {
219
+ root: null, // viewport
220
+ rootMargin: '0px',
221
+ threshold,
222
+ });
223
+ lazyObservers.set(threshold, { observer, ctor: currentCtor });
224
+ return observer;
225
+ }
226
+ /**
227
+ * Observe an element for lazy loading when it enters viewport
228
+ */
229
+ export function observeLazyElement(el, loadCallback, threshold = 0) {
230
+ if (typeof globalThis.IntersectionObserver !== 'function') {
231
+ // Graceful fallback for environments without IntersectionObserver.
232
+ // Load only if still connected after the current bind pass.
233
+ // This avoids eager loading elements removed by directives like d-if.
234
+ let canceled = false;
235
+ queueMicrotask(() => {
236
+ if (canceled)
237
+ return;
238
+ if (el.isConnected) {
239
+ loadCallback();
240
+ }
241
+ });
242
+ return () => {
243
+ canceled = true;
244
+ };
245
+ }
246
+ const observer = getLazyIntersectionObserver(threshold);
247
+ lazyObserverElements.set(el, { callback: loadCallback, threshold });
248
+ observer.observe(el);
249
+ // Return cleanup function
250
+ return () => {
251
+ lazyObserverElements.delete(el);
252
+ observer.unobserve(el);
253
+ };
254
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dalila",
3
- "version": "1.9.8",
3
+ "version": "1.9.9",
4
4
  "description": "DOM-first reactive framework based on signals",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",