gonia 0.3.4 → 0.3.6

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
@@ -4,9 +4,9 @@ A lightweight, SSR-first reactive UI library for building web applications with
4
4
 
5
5
  ## Features
6
6
 
7
- - **SSR-First Architecture** - Server-side rendering with seamless client hydration
8
- - **Declarative Directives** - Vue-inspired template syntax (`g-text`, `g-for`, `g-if`, etc.)
9
- - **Fine-Grained Reactivity** - Efficient updates without virtual DOM diffing
7
+ - **[SSR-First Architecture](./docs/ssr.md)** - Server-side rendering with seamless client hydration
8
+ - **[Declarative Directives](./docs/directives.md)** - Vue-inspired template syntax (`g-text`, `g-for`, `g-if`, etc.)
9
+ - **[Fine-Grained Reactivity](./docs/reactivity.md)** - Efficient updates without virtual DOM diffing
10
10
  - **Zero Dependencies** - Core library has no runtime dependencies (linkedom for SSR only)
11
11
  - **TypeScript Native** - Full type safety with excellent IDE support
12
12
 
@@ -55,6 +55,8 @@ hydrate();
55
55
 
56
56
  ### Creating a Component Directive
57
57
 
58
+ Directives receive their dependencies through [dependency injection](./docs/directives.md#dependency-injection) — parameter names like `$element` and `$scope` tell the framework what to provide:
59
+
58
60
  ```typescript
59
61
  import { directive, Directive } from 'gonia';
60
62
 
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Shared utilities for async directive handling.
3
+ *
4
+ * @packageDocumentation
5
+ */
6
+ /**
7
+ * Thrown by `$fallback()` to signal that the directive wants fallback rendering.
8
+ * The framework catches this at the directive execution boundary.
9
+ */
10
+ export declare class FallbackSignal {
11
+ readonly isFallbackSignal = true;
12
+ }
13
+ /**
14
+ * Detect whether a function is async (declared with `async` keyword).
15
+ *
16
+ * @param fn - The function to check
17
+ * @returns true if fn is an AsyncFunction
18
+ */
19
+ export declare function isAsyncFunction(fn: Function): boolean;
20
+ /**
21
+ * Generate a unique ID for streaming placeholders.
22
+ *
23
+ * @returns A unique string ID like "g-async-0", "g-async-1", etc.
24
+ */
25
+ export declare function generateAsyncId(): string;
26
+ /**
27
+ * Reset the async ID counter. For testing only.
28
+ */
29
+ export declare function resetAsyncIdCounter(): void;
package/dist/async.js ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Shared utilities for async directive handling.
3
+ *
4
+ * @packageDocumentation
5
+ */
6
+ /**
7
+ * Thrown by `$fallback()` to signal that the directive wants fallback rendering.
8
+ * The framework catches this at the directive execution boundary.
9
+ */
10
+ export class FallbackSignal {
11
+ isFallbackSignal = true;
12
+ }
13
+ /**
14
+ * Detect whether a function is async (declared with `async` keyword).
15
+ *
16
+ * @param fn - The function to check
17
+ * @returns true if fn is an AsyncFunction
18
+ */
19
+ export function isAsyncFunction(fn) {
20
+ return fn.constructor.name === 'AsyncFunction';
21
+ }
22
+ /** Counter for generating unique streaming IDs */
23
+ let asyncIdCounter = 0;
24
+ /**
25
+ * Generate a unique ID for streaming placeholders.
26
+ *
27
+ * @returns A unique string ID like "g-async-0", "g-async-1", etc.
28
+ */
29
+ export function generateAsyncId() {
30
+ return `g-async-${asyncIdCounter++}`;
31
+ }
32
+ /**
33
+ * Reset the async ID counter. For testing only.
34
+ */
35
+ export function resetAsyncIdCounter() {
36
+ asyncIdCounter = 0;
37
+ }
@@ -4,6 +4,7 @@
4
4
  * @packageDocumentation
5
5
  */
6
6
  import { Mode, DirectivePriority, getDirective, getDirectiveNames } from '../types.js';
7
+ import { isAsyncFunction, FallbackSignal } from '../async.js';
7
8
  import { createContext } from '../context.js';
8
9
  import { processNativeSlot } from '../directives/slot.js';
9
10
  import { getLocalState, registerProvider, registerDIProviders } from '../providers.js';
@@ -369,8 +370,8 @@ function getCustomElementSelector() {
369
370
  if (!registration)
370
371
  continue;
371
372
  const { options } = registration;
372
- // Only include directives with templates, scope, provide, or using
373
- if (options.template || options.scope || options.provide || options.using) {
373
+ // Only include directives with templates, scope, provide, using, or fallback
374
+ if (options.template || options.scope || options.provide || options.using || options.fallback) {
374
375
  selectors.push(name);
375
376
  }
376
377
  }
@@ -433,31 +434,148 @@ async function processDirectiveElements() {
433
434
  if (options.provide) {
434
435
  registerDIProviders(el, options.provide);
435
436
  }
436
- // 3. Call directive function if present (initializes state)
437
- if (fn) {
438
- const ctx = createContext(Mode.CLIENT, scope);
439
- const config = createClientResolverConfig(el, () => scope, services);
440
- const args = resolveInjectables(fn, '', el, ctx.eval.bind(ctx), config, options.using);
441
- const result = fn(...args);
442
- if (result instanceof Promise) {
443
- await result;
444
- }
437
+ // Async directive handling
438
+ const fnIsAsync = fn && isAsyncFunction(fn);
439
+ const hasFallback = options.fallback !== undefined;
440
+ const asyncState = el.getAttribute('data-g-async');
441
+ if (fnIsAsync && hasFallback) {
442
+ await processAsyncDirectiveElement(el, fn, options, scope, asyncState);
445
443
  }
446
- // 4. Render template if present (can query DOM for <template> elements etc)
447
- if (options.template) {
448
- const attrs = getTemplateAttrs(el);
449
- let html;
450
- if (typeof options.template === 'string') {
451
- html = options.template;
444
+ else {
445
+ // 3. Call directive function if present (initializes state)
446
+ if (fn) {
447
+ const ctx = createContext(Mode.CLIENT, scope);
448
+ const config = createClientResolverConfig(el, () => scope, services);
449
+ const args = resolveInjectables(fn, '', el, ctx.eval.bind(ctx), config, options.using);
450
+ const result = fn(...args);
451
+ if (result instanceof Promise) {
452
+ await result;
453
+ }
452
454
  }
453
- else {
454
- const result = options.template(attrs, el);
455
- html = result instanceof Promise ? await result : result;
455
+ // 4. Render template if present (can query DOM for <template> elements etc)
456
+ if (options.template) {
457
+ if (el.hasAttribute('data-g-prerendered')) {
458
+ el.removeAttribute('data-g-prerendered');
459
+ }
460
+ else {
461
+ const attrs = getTemplateAttrs(el);
462
+ let html;
463
+ if (typeof options.template === 'string') {
464
+ html = options.template;
465
+ }
466
+ else {
467
+ const result = options.template(attrs, el);
468
+ html = result instanceof Promise ? await result : result;
469
+ }
470
+ el.innerHTML = html;
471
+ }
456
472
  }
457
- el.innerHTML = html;
458
473
  }
459
474
  }
460
475
  }
476
+ /**
477
+ * Process an async directive element based on its SSR state.
478
+ *
479
+ * @internal
480
+ */
481
+ async function processAsyncDirectiveElement(el, fn, options, scope, asyncState) {
482
+ const ctx = createContext(Mode.CLIENT, scope);
483
+ const config = createClientResolverConfig(el, () => scope, services);
484
+ const args = resolveInjectables(fn, '', el, ctx.eval.bind(ctx), config, options.using);
485
+ if (asyncState === 'loaded') {
486
+ // SSR already rendered the template — just run fn for reactivity setup
487
+ try {
488
+ await fn(...args);
489
+ }
490
+ catch (e) {
491
+ if (e instanceof FallbackSignal) {
492
+ await renderClientFallback(el, options);
493
+ el.setAttribute('data-g-async', 'pending');
494
+ return;
495
+ }
496
+ }
497
+ if (el.hasAttribute('data-g-prerendered')) {
498
+ el.removeAttribute('data-g-prerendered');
499
+ }
500
+ if (fn.$context?.length) {
501
+ const state = getLocalState(el);
502
+ registerProvider(el, fn, state);
503
+ }
504
+ }
505
+ else if (asyncState === 'pending' || asyncState === 'streaming' || asyncState === 'timeout') {
506
+ // SSR rendered fallback — run fn, swap to template on success
507
+ const ok = await runAsyncAndSwap(el, fn, args, options);
508
+ if (!ok)
509
+ return;
510
+ el.removeAttribute('data-g-async-id');
511
+ }
512
+ else {
513
+ // Pure client (no SSR attribute) — render fallback first, then swap
514
+ await renderClientFallback(el, options);
515
+ el.setAttribute('data-g-async', 'pending');
516
+ await runAsyncAndSwap(el, fn, args, options);
517
+ }
518
+ }
519
+ /**
520
+ * Run an async directive function, swap to template on success, and register provider.
521
+ * Returns false if the fn threw FallbackSignal or an error.
522
+ *
523
+ * @internal
524
+ */
525
+ async function runAsyncAndSwap(el, fn, args, options) {
526
+ try {
527
+ await fn(...args);
528
+ }
529
+ catch (e) {
530
+ if (e instanceof FallbackSignal)
531
+ return false;
532
+ el.setAttribute('data-g-async', 'error');
533
+ return false;
534
+ }
535
+ await renderTemplateSwap(el, options);
536
+ el.setAttribute('data-g-async', 'loaded');
537
+ if (fn.$context?.length) {
538
+ const state = getLocalState(el);
539
+ registerProvider(el, fn, state);
540
+ }
541
+ return true;
542
+ }
543
+ /**
544
+ * Swap element content to its template.
545
+ *
546
+ * @internal
547
+ */
548
+ async function renderTemplateSwap(el, options) {
549
+ if (!options.template)
550
+ return;
551
+ const attrs = getTemplateAttrs(el);
552
+ let html;
553
+ if (typeof options.template === 'string') {
554
+ html = options.template;
555
+ }
556
+ else {
557
+ const result = options.template(attrs, el);
558
+ html = result instanceof Promise ? await result : result;
559
+ }
560
+ el.innerHTML = html;
561
+ }
562
+ /**
563
+ * Render fallback content for an async directive on the client.
564
+ *
565
+ * @internal
566
+ */
567
+ async function renderClientFallback(el, options) {
568
+ if (!options.fallback)
569
+ return;
570
+ if (typeof options.fallback === 'string') {
571
+ el.innerHTML = options.fallback;
572
+ }
573
+ else {
574
+ const attrs = getTemplateAttrs(el);
575
+ const result = options.fallback(attrs, el);
576
+ el.innerHTML = result instanceof Promise ? await result : result;
577
+ }
578
+ }
461
579
  export async function init(registry) {
462
580
  const reg = registry ?? getDefaultRegistry();
463
581
  cachedSelector = null;
@@ -498,6 +616,12 @@ export async function init(registry) {
498
616
  }
499
617
  });
500
618
  observer.observe(document.body, { childList: true, subtree: true });
619
+ // Streaming hydration hook: called by inline scripts from renderStream()
620
+ if (typeof window !== 'undefined') {
621
+ window.__gonia_hydrate = (el) => {
622
+ processNode(el, selector, reg);
623
+ };
624
+ }
501
625
  initialized = true;
502
626
  }
503
627
  /**
@@ -95,6 +95,27 @@ function removeSSRItems(templateEl) {
95
95
  sibling = next;
96
96
  }
97
97
  }
98
+ /**
99
+ * Set up a reactive effect that re-renders loop items when dependencies change.
100
+ *
101
+ * @internal
102
+ */
103
+ function setupReactiveLoop(templateContent, parent, templateWrapper, parsed, $eval, $scope) {
104
+ let renderedElements = [];
105
+ let scope = null;
106
+ effect(() => {
107
+ if (scope) {
108
+ scope.stop();
109
+ }
110
+ for (const el of renderedElements) {
111
+ el.remove();
112
+ }
113
+ scope = createEffectScope();
114
+ scope.run(() => {
115
+ renderedElements = renderItems(templateContent, parent, templateWrapper, parsed, $eval, $scope, Mode.CLIENT);
116
+ });
117
+ });
118
+ }
98
119
  /**
99
120
  * Iterate over array or object items.
100
121
  *
@@ -154,21 +175,7 @@ export const cfor = function cfor($expr, $element, $eval, $scope, $mode) {
154
175
  }
155
176
  // Remove SSR-rendered items
156
177
  removeSSRItems(templateWrapper);
157
- // Set up reactive loop
158
- let renderedElements = [];
159
- let scope = null;
160
- effect(() => {
161
- if (scope) {
162
- scope.stop();
163
- }
164
- for (const el of renderedElements) {
165
- el.remove();
166
- }
167
- scope = createEffectScope();
168
- scope.run(() => {
169
- renderedElements = renderItems(templateContent, parent, templateWrapper, parsed, $eval, $scope, Mode.CLIENT);
170
- });
171
- });
178
+ setupReactiveLoop(templateContent, parent, templateWrapper, parsed, $eval, $scope);
172
179
  }
173
180
  else {
174
181
  // Fresh client render: element has g-for attribute directly
@@ -179,21 +186,7 @@ export const cfor = function cfor($expr, $element, $eval, $scope, $mode) {
179
186
  templateContent.removeAttribute('g-for');
180
187
  templateWrapper.content.appendChild(templateContent);
181
188
  parent.replaceChild(templateWrapper, $element);
182
- // Set up reactive loop
183
- let renderedElements = [];
184
- let scope = null;
185
- effect(() => {
186
- if (scope) {
187
- scope.stop();
188
- }
189
- for (const el of renderedElements) {
190
- el.remove();
191
- }
192
- scope = createEffectScope();
193
- scope.run(() => {
194
- renderedElements = renderItems(templateContent, parent, templateWrapper, parsed, $eval, $scope, Mode.CLIENT);
195
- });
196
- });
189
+ setupReactiveLoop(templateContent, parent, templateWrapper, parsed, $eval, $scope);
197
190
  }
198
191
  };
199
192
  cfor.$inject = ['$expr', '$element', '$eval', '$scope', '$mode'];
package/dist/index.d.ts CHANGED
@@ -8,7 +8,8 @@
8
8
  * @packageDocumentation
9
9
  */
10
10
  export { Mode, Expression, Context, Directive, directive, getDirective, getDirectiveNames, clearDirectives, configureDirective } from './types.js';
11
- export type { DirectiveMeta } from './types.js';
11
+ export type { DirectiveMeta, RenderOptions, FallbackOption } from './types.js';
12
+ export { isAsyncFunction, FallbackSignal } from './async.js';
12
13
  export { createContext, createChildContext } from './context.js';
13
14
  export { reactive, effect, createScope, createEffectScope } from './reactivity.js';
14
15
  export type { EffectScope } from './reactivity.js';
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@
8
8
  * @packageDocumentation
9
9
  */
10
10
  export { Mode, directive, getDirective, getDirectiveNames, clearDirectives, configureDirective } from './types.js';
11
+ export { isAsyncFunction, FallbackSignal } from './async.js';
11
12
  export { createContext, createChildContext } from './context.js';
12
13
  export { reactive, effect, createScope, createEffectScope } from './reactivity.js';
13
14
  export { createTemplateRegistry, createMemoryRegistry, createServerRegistry } from './templates.js';
package/dist/inject.js CHANGED
@@ -11,6 +11,7 @@
11
11
  *
12
12
  * @packageDocumentation
13
13
  */
14
+ import { FallbackSignal } from './async.js';
14
15
  /**
15
16
  * Check if a value is a ContextKey.
16
17
  */
@@ -87,6 +88,12 @@ function parseFunctionParams(fn) {
87
88
  export function resolveDependencies(fn, expr, element, evalFn, config, using) {
88
89
  const inject = getInjectables(fn);
89
90
  const args = inject.map(dep => {
91
+ if (dep.startsWith('_')) {
92
+ if (process.env.NODE_ENV !== 'production') {
93
+ console.warn(`Injectable '${dep}' starts with underscore — passing undefined.`);
94
+ }
95
+ return undefined;
96
+ }
90
97
  switch (dep) {
91
98
  case '$expr':
92
99
  return expr;
@@ -100,6 +107,8 @@ export function resolveDependencies(fn, expr, element, evalFn, config, using) {
100
107
  return config.resolveRootState?.() ?? config.resolveState();
101
108
  case '$mode':
102
109
  return config.mode;
110
+ case '$fallback':
111
+ return () => { throw new FallbackSignal(); };
103
112
  default: {
104
113
  // Look up in custom resolver (services, providers, etc.)
105
114
  if (config.resolveCustom) {
package/dist/scope.js CHANGED
@@ -109,6 +109,8 @@ export function removeElementScope(el) {
109
109
  export function registerDirectiveElement(name,
110
110
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
111
111
  fn, options) {
112
+ if (typeof customElements === 'undefined')
113
+ return;
112
114
  // Don't re-register if already defined
113
115
  if (customElements.get(name)) {
114
116
  return;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Async directive rendering utilities for server-side rendering.
3
+ *
4
+ * @packageDocumentation
5
+ */
6
+ import { Directive, Expression, FallbackOption } from '../types.js';
7
+ import { ContextKey } from '../context-registry.js';
8
+ /**
9
+ * A pending stream chunk for async directives in stream mode.
10
+ * Used by renderStream() to emit replacement scripts after initial HTML.
11
+ *
12
+ * @internal
13
+ */
14
+ export interface StreamPendingChunk {
15
+ asyncId: string;
16
+ el: Element;
17
+ fn: Directive;
18
+ options: {
19
+ template?: FallbackOption;
20
+ [key: string]: unknown;
21
+ };
22
+ scopeState: Record<string, unknown>;
23
+ rootState: Record<string, unknown>;
24
+ expr: Expression;
25
+ using?: ContextKey<unknown>[];
26
+ }
27
+ /**
28
+ * Get the nesting depth of an async directive by walking up the DOM.
29
+ *
30
+ * @internal
31
+ */
32
+ export declare function getAsyncDepth(el: Element, depthMap: WeakMap<Element, number>): number;
33
+ /**
34
+ * Render fallback content for an async directive.
35
+ *
36
+ * @internal
37
+ */
38
+ export declare function renderFallback(el: Element, fallback: FallbackOption, options: {
39
+ template?: unknown;
40
+ [key: string]: unknown;
41
+ }, ssrMode: string, streamCtx?: {
42
+ fn: Directive;
43
+ scopeState: Record<string, unknown>;
44
+ rootState: Record<string, unknown>;
45
+ expr: Expression;
46
+ using?: ContextKey<unknown>[];
47
+ streamPending: StreamPendingChunk[];
48
+ }): Promise<void>;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Async directive rendering utilities for server-side rendering.
3
+ *
4
+ * @packageDocumentation
5
+ */
6
+ import { getTemplateAttrs } from '../template-utils.js';
7
+ import { generateAsyncId } from '../async.js';
8
+ /**
9
+ * Get the nesting depth of an async directive by walking up the DOM.
10
+ *
11
+ * @internal
12
+ */
13
+ export function getAsyncDepth(el, depthMap) {
14
+ let current = el.parentElement;
15
+ while (current) {
16
+ const depth = depthMap.get(current);
17
+ if (depth !== undefined) {
18
+ return depth;
19
+ }
20
+ current = current.parentElement;
21
+ }
22
+ return 0;
23
+ }
24
+ /**
25
+ * Render fallback content for an async directive.
26
+ *
27
+ * @internal
28
+ */
29
+ export async function renderFallback(el, fallback, options, ssrMode, streamCtx) {
30
+ let fallbackHtml;
31
+ if (typeof fallback === 'string') {
32
+ fallbackHtml = fallback;
33
+ }
34
+ else {
35
+ const attrs = getTemplateAttrs(el);
36
+ const result = fallback(attrs, el);
37
+ fallbackHtml = result instanceof Promise ? await result : result;
38
+ }
39
+ el.innerHTML = fallbackHtml;
40
+ if (ssrMode === 'stream') {
41
+ const asyncId = generateAsyncId();
42
+ el.setAttribute('data-g-async', 'streaming');
43
+ el.setAttribute('data-g-async-id', asyncId);
44
+ if (streamCtx) {
45
+ streamCtx.streamPending.push({
46
+ asyncId,
47
+ el,
48
+ fn: streamCtx.fn,
49
+ options: options,
50
+ scopeState: streamCtx.scopeState,
51
+ rootState: streamCtx.rootState,
52
+ expr: streamCtx.expr,
53
+ using: streamCtx.using,
54
+ });
55
+ }
56
+ }
57
+ else {
58
+ el.setAttribute('data-g-async', 'pending');
59
+ }
60
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * DOM tree indexing for server-side directive discovery.
3
+ *
4
+ * @packageDocumentation
5
+ */
6
+ import { Expression, Directive } from '../types.js';
7
+ import { ContextKey } from '../context-registry.js';
8
+ import type { DirectiveRegistry } from './render.js';
9
+ /**
10
+ * An indexed directive instance found in the DOM.
11
+ *
12
+ * @internal
13
+ */
14
+ export interface IndexedDirective {
15
+ el: Element;
16
+ name: string;
17
+ directive: Directive | null;
18
+ expr: Expression;
19
+ priority: number;
20
+ isNativeSlot?: boolean;
21
+ isCustomElement?: boolean;
22
+ using?: ContextKey<unknown>[];
23
+ }
24
+ /**
25
+ * Build a CSS selector for all registered directives.
26
+ * Uses the global directive registry to support any prefix (g-, l-, v-, etc.).
27
+ * Also includes local registry entries with g- prefix for backward compatibility.
28
+ *
29
+ * @internal
30
+ */
31
+ export declare function getSelector(localRegistry?: DirectiveRegistry): string;
32
+ /**
33
+ * Index all directive elements in a subtree.
34
+ * Discovers elements matching the selector and builds an ordered list
35
+ * of directives to process.
36
+ *
37
+ * @param root - The DOM subtree root to scan
38
+ * @param selector - CSS selector matching directive elements
39
+ * @param registry - Local directive registry for backward compatibility
40
+ * @param index - Accumulator for discovered directives (mutated in place)
41
+ * @param indexed - Set tracking already-indexed elements (mutated in place)
42
+ *
43
+ * @internal
44
+ */
45
+ export declare function indexTree(root: any, selector: string, registry: DirectiveRegistry, index: IndexedDirective[], indexed: Set<Element>): void;