micra.js 2.0.0 → 2.2.0

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.
@@ -0,0 +1,189 @@
1
+ /**
2
+ * src/dom/scan.ts — Single-pass directive/event/ref scanner.
3
+ *
4
+ * Replaces 10+ querySelectorAll calls per render with ONE TreeWalker
5
+ * traversal that classifies every directive attribute in a single visit.
6
+ *
7
+ * Boundaries:
8
+ * - REJECT (skip subtree) on nested [data-component] — same semantics as
9
+ * the old `filterOwn` helper, but applied during the walk so we don't
10
+ * even *visit* those nodes.
11
+ * - <template> contents are not visited (browser TreeWalker default).
12
+ * `<template data-each>` itself IS visited and classified into scan.each;
13
+ * its children are processed by each.ts on every render via scanFragment.
14
+ *
15
+ * Hot-path notes:
16
+ * - We read `el.attributes` once and switch by suffix. No allocations per
17
+ * non-matching attr.
18
+ * - Pair-parsing (`data-bind`, `data-class`) happens here, once, at scan
19
+ * time. Reused on every render.
20
+ */
21
+
22
+ import type { CachedIfBinding, CachedPairBinding, ScanIndex } from "../types";
23
+
24
+ function emptyScan(): ScanIndex {
25
+ return {
26
+ text: [],
27
+ html: [],
28
+ if: [],
29
+ show: [],
30
+ bind: [],
31
+ model: [],
32
+ class: [],
33
+ each: [],
34
+ on: [],
35
+ atEvents: [],
36
+ refs: [],
37
+ };
38
+ }
39
+
40
+ /** @internal Parse `name:expr, name2:expr2` once at scan time. */
41
+ function parsePairs(expr: string): Array<readonly [string, string]> {
42
+ const out: Array<readonly [string, string]> = [];
43
+ for (const part of expr.split(",")) {
44
+ const colon = part.indexOf(":");
45
+ if (colon === -1) continue;
46
+ const left = part.slice(0, colon).trim();
47
+ const right = part.slice(colon + 1).trim();
48
+ if (!left) continue;
49
+ out.push([left, right]);
50
+ }
51
+ return out;
52
+ }
53
+
54
+ /** @internal Classify every relevant attribute on one element. */
55
+ function classify(el: Element, scan: ScanIndex): void {
56
+ // <template data-each> is the only directive we permit on a <template>.
57
+ // Other directives on a <template> would be meaningless (the content lives
58
+ // in template.content, not template.children).
59
+ if (el.tagName === "TEMPLATE") {
60
+ if (el.hasAttribute("data-each")) scan.each.push(el);
61
+ return;
62
+ }
63
+
64
+ const attrs = el.attributes;
65
+ let atEventSeen = false;
66
+
67
+ for (let i = 0; i < attrs.length; i++) {
68
+ const a = attrs[i]!;
69
+ const name = a.name;
70
+
71
+ // Fast path: most attribute names aren't ours. First-char check rejects
72
+ // the common case (id, class, style, href, …) without a string compare.
73
+ const first = name.charCodeAt(0);
74
+
75
+ if (first === 64 /* '@' */) {
76
+ // @event="method" or @event.modifier="method"
77
+ if (!atEventSeen) {
78
+ scan.atEvents.push(el);
79
+ atEventSeen = true;
80
+ }
81
+ continue;
82
+ }
83
+
84
+ // data-X attributes
85
+ if (
86
+ first === 100 /* d */ &&
87
+ name.length >= 6 &&
88
+ name.charCodeAt(4) === 45 /* '-' */
89
+ ) {
90
+ // 'data-' prefix
91
+ const rest = name.slice(5);
92
+ switch (rest) {
93
+ case "text":
94
+ scan.text.push({ el, expr: a.value });
95
+ break;
96
+ case "html":
97
+ scan.html.push({ el, expr: a.value });
98
+ break;
99
+ case "if":
100
+ scan.if.push({ el, expr: a.value } as CachedIfBinding);
101
+ break;
102
+ case "show":
103
+ scan.show.push({ el, expr: a.value });
104
+ break;
105
+ case "bind": {
106
+ const pairs = parsePairs(a.value);
107
+ scan.bind.push({ el, expr: a.value, pairs } as CachedPairBinding);
108
+ break;
109
+ }
110
+ case "model":
111
+ scan.model.push({ el, expr: a.value });
112
+ break;
113
+ case "class": {
114
+ const pairs = parsePairs(a.value);
115
+ scan.class.push({ el, expr: a.value, pairs } as CachedPairBinding);
116
+ break;
117
+ }
118
+ case "on":
119
+ scan.on.push(el);
120
+ break;
121
+ case "ref":
122
+ scan.refs.push(el);
123
+ break;
124
+ // data-key, data-each, data-component, data-* user attrs — ignored here
125
+ }
126
+ }
127
+ }
128
+ }
129
+
130
+ /** @internal Shared filter: stop descending into nested components. */
131
+ const NESTED_COMPONENT_FILTER: NodeFilter = {
132
+ acceptNode(node: Node): number {
133
+ if ((node as Element).hasAttribute("data-component"))
134
+ return NodeFilter.FILTER_REJECT;
135
+ return NodeFilter.FILTER_ACCEPT;
136
+ },
137
+ };
138
+
139
+ /**
140
+ * Scan an Element subtree owned by one component. Skips nested
141
+ * [data-component] subtrees entirely. Visits the root itself.
142
+ *
143
+ * Cached on `el.__micraScan` after the first call — subsequent renders
144
+ * are free.
145
+ */
146
+ export function scanComponent(root: Element): ScanIndex {
147
+ const scan = emptyScan();
148
+
149
+ // Always classify root itself first — the TreeWalker's filter would
150
+ // REJECT it if it had `data-component` (which it normally does for a
151
+ // mounted component). The filter is for *descendants*.
152
+ classify(root, scan);
153
+
154
+ const walker = document.createTreeWalker(
155
+ root,
156
+ NodeFilter.SHOW_ELEMENT,
157
+ NESTED_COMPONENT_FILTER,
158
+ );
159
+
160
+ let node: Element | null = walker.nextNode() as Element | null;
161
+ while (node) {
162
+ classify(node, scan);
163
+ node = walker.nextNode() as Element | null;
164
+ }
165
+
166
+ return scan;
167
+ }
168
+
169
+ /**
170
+ * Scan a DocumentFragment (no-key each clone). Not cached — these fragments
171
+ * are temporary and re-cloned every render.
172
+ */
173
+ export function scanFragment(frag: DocumentFragment): ScanIndex {
174
+ const scan = emptyScan();
175
+
176
+ const walker = document.createTreeWalker(
177
+ frag,
178
+ NodeFilter.SHOW_ELEMENT,
179
+ NESTED_COMPONENT_FILTER,
180
+ );
181
+
182
+ let node: Element | null = walker.nextNode() as Element | null;
183
+ while (node) {
184
+ classify(node, scan);
185
+ node = walker.nextNode() as Element | null;
186
+ }
187
+
188
+ return scan;
189
+ }
package/src/index.ts CHANGED
@@ -28,6 +28,8 @@ export type {
28
28
  UnsubFn,
29
29
  EventHandler,
30
30
  FetchOptions,
31
+ ComponentMethods,
32
+ ComponentBuiltins,
31
33
  ComponentInstance,
32
34
  ComponentDefinition,
33
35
  } from './types'
package/src/types.ts CHANGED
@@ -32,14 +32,23 @@ export interface FetchOptions {
32
32
  }
33
33
 
34
34
  /**
35
- * The `this` context inside component methods and lifecycle hooks.
36
- * `S` is inferred from the component's `state` object.
35
+ * User-defined methods on a component definition. Any function-shaped property
36
+ * other than `state`, `onCreate`, `onDestroy` is treated as a method.
37
37
  *
38
- * @example
39
- * // state: { count: 0 } S = { count: number }
40
- * increment() { this.state.count++ } // count is number
38
+ * LLM NOTE: this type is used as a structural HINT only — `M` in
39
+ * ComponentDefinition is unconstrained so TS can infer it from the literal
40
+ * without rejecting non-function siblings like `state`. The `[key: string]`
41
+ * shape here just documents intent.
42
+ */
43
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
44
+ export type ComponentMethods = Record<string, (...args: any[]) => any>
45
+
46
+ /**
47
+ * Built-in slots every instance gets: state, refs, $el, and the methods Micra
48
+ * itself injects (render, destroy, prop, fetch, emit, on). Kept separate from
49
+ * `M` so user methods can't accidentally shadow these names.
41
50
  */
42
- export interface ComponentInstance<S extends StateRecord = StateRecord> {
51
+ export interface ComponentBuiltins<S extends StateRecord = StateRecord> {
43
52
  /** The root DOM element this component is mounted on. */
44
53
  readonly $el: HTMLElement
45
54
  /** Reactive state — any assignment triggers a batched re-render. */
@@ -68,11 +77,40 @@ export interface ComponentInstance<S extends StateRecord = StateRecord> {
68
77
  on<T = unknown>(event: string, handler: EventHandler<T>): UnsubFn
69
78
  }
70
79
 
80
+ /**
81
+ * The `this` context inside component methods and lifecycle hooks.
82
+ * `S` is inferred from the component's `state` object; `M` is inferred from
83
+ * the methods on the same definition object. Both `this.state.X` and
84
+ * `this.someMethod()` are fully typed inside method bodies.
85
+ *
86
+ * @example
87
+ * Micra.define('counter', {
88
+ * state: { count: 0 },
89
+ * inc() {
90
+ * this.state.count++ // this.state.count: number ✓
91
+ * this.dec() // this.dec: () => void ✓
92
+ * // this.foo() // ❌ Property 'foo' does not exist
93
+ * },
94
+ * dec() { this.state.count-- },
95
+ * })
96
+ */
97
+ export type ComponentInstance<
98
+ S extends StateRecord = StateRecord,
99
+ M = ComponentMethods,
100
+ > = ComponentBuiltins<S> & M
101
+
71
102
  /**
72
103
  * Component definition passed to `Micra.define` or `Micra.mount`.
73
104
  *
74
- * `S` is inferred from the `state` property all methods receive
75
- * `this: ComponentInstance<S>` automatically via `ThisType<>`.
105
+ * Both `S` (state shape) and `M` (method set) are inferred from the literal.
106
+ * All methods and lifecycle hooks receive `this: ComponentInstance<S, M>` via
107
+ * `ThisType<>` — so `this.state.X` and `this.someMethod()` are both typed.
108
+ *
109
+ * LLM NOTE: `M` is intentionally unconstrained here. A constraint like
110
+ * `M extends ComponentMethods` would force every property in the literal to
111
+ * be a function — which would reject `state`. Without the constraint, TS
112
+ * structurally infers `M` as "everything in the literal except the known
113
+ * lifecycle/state keys", which is exactly what we want.
76
114
  *
77
115
  * @example
78
116
  * Micra.define('counter', {
@@ -80,21 +118,23 @@ export interface ComponentInstance<S extends StateRecord = StateRecord> {
80
118
  * inc() { this.state.count++ }, // this.state.count: number ✓
81
119
  * })
82
120
  */
83
- export type ComponentDefinition<S extends StateRecord = StateRecord> = {
121
+ export type ComponentDefinition<
122
+ S extends StateRecord = StateRecord,
123
+ M = ComponentMethods,
124
+ > = {
84
125
  /** Initial flat state. Becomes reactive on mount. */
85
126
  state?: S
86
127
  /**
87
128
  * Called once after mount in a microtask — safe for async data fetching.
88
129
  * @example async onCreate() { this.state.data = await this.fetch('/api/data') }
89
130
  */
90
- onCreate?: () => void | Promise<void>
131
+ onCreate?(): void | Promise<void>
91
132
  /**
92
133
  * Called on destroy — clean up DOM listeners, timers, etc.
93
134
  * Event bus subscriptions added via `this.on()` are cleaned up automatically.
94
135
  */
95
- onDestroy?: () => void
96
- [method: string]: unknown
97
- } & ThisType<ComponentInstance<S>>
136
+ onDestroy?(): void
137
+ } & M & ThisType<ComponentInstance<S, M>>
98
138
 
99
139
  // ── Internal types ────────────────────────────────────────────────────────────
100
140
  // These are NOT exported from src/index.ts.
@@ -108,7 +148,7 @@ export interface MicraElement extends HTMLElement {
108
148
  __micraAtBound?: true // @event shorthand bound (per-element)
109
149
  __micraKey?: unknown // keyed-diff key
110
150
  __micraEach?: true // belongs to a no-key each list
111
- __micraCache?: DirectiveCache // cached directive scan result
151
+ __micraScan?: ScanIndex // single-pass scan result (cached after 1st render)
112
152
  }
113
153
 
114
154
  /**
@@ -159,13 +199,16 @@ export interface CachedPairBinding {
159
199
  }
160
200
 
161
201
  /**
162
- * @internal Directive scan result — built once per Element, reused every render.
163
- * This is the core of the performance optimization.
202
+ * @internal Single-pass scan result — built once per Element via one TreeWalker
203
+ * traversal, reused every render. This is the core of the performance
204
+ * optimization: instead of 10+ querySelectorAll calls per render, the scanner
205
+ * classifies every directive/event/ref attribute in a single DOM walk.
164
206
  *
165
- * LLM NOTE: DirectiveCache is built lazily on first render and stored on the
166
- * element. It avoids repeated querySelectorAll calls on every re-render.
207
+ * LLM NOTE: ScanIndex is built lazily on first render and stored on
208
+ * `el.__micraScan`. Subsequent renders skip the scan entirely.
167
209
  */
168
- export interface DirectiveCache {
210
+ export interface ScanIndex {
211
+ // Directives — applied on every render
169
212
  text: CachedBinding[]
170
213
  html: CachedBinding[]
171
214
  if: CachedIfBinding[]
@@ -173,15 +216,27 @@ export interface DirectiveCache {
173
216
  bind: CachedPairBinding[]
174
217
  model: CachedBinding[]
175
218
  class: CachedPairBinding[]
219
+ // Lists — <template data-each>
220
+ each: Element[]
221
+ // Events — bound once per element
222
+ on: Element[] // [data-on]
223
+ atEvents: Element[] // any element with at least one @-prefixed attribute
224
+ // Refs — collected into instance.refs every render
225
+ refs: Element[] // [data-ref]
176
226
  }
177
227
 
178
228
  /**
179
229
  * @internal Full instance as seen inside the runtime — extends the public
180
- * interface with private bookkeeping slots and an index signature for
230
+ * built-ins with private bookkeeping slots and an index signature for
181
231
  * dynamic method dispatch.
232
+ *
233
+ * Note: internally we don't carry the user-method generic `M`. Internal modules
234
+ * dispatch methods by string name (`instance[methodName]()`), which is what
235
+ * the index signature is for. The public `mount()` return value re-projects
236
+ * to `ComponentInstance<S, M>` so callers get full type inference.
182
237
  */
183
238
  export interface InternalInstance<S extends StateRecord = StateRecord>
184
- extends ComponentInstance<S> {
239
+ extends ComponentBuiltins<S> {
185
240
  __micraSubs?: UnsubFn[]
186
241
  __micraListeners?: TrackedListener[]
187
242
  __micraDestroyed?: true
@@ -77,7 +77,8 @@ export async function micraFetch(url: string, options: FetchOptions = {}): Promi
77
77
  if (method === 'GET' || method === 'HEAD') {
78
78
  const params: Record<string, string> = {}
79
79
  for (const [k, v] of Object.entries(options)) {
80
- if (k !== 'method' && k !== 'headers' && v != null) params[k] = String(v)
80
+ if (k !== 'method' && k !== 'headers' && k !== 'signal' && v != null)
81
+ params[k] = String(v)
81
82
  }
82
83
  if (Object.keys(params).length)
83
84
  finalUrl += (url.includes('?') ? '&' : '?') + new URLSearchParams(params)
@@ -89,6 +90,7 @@ export async function micraFetch(url: string, options: FetchOptions = {}): Promi
89
90
  const res = await fetch(finalUrl, {
90
91
  method,
91
92
  headers,
93
+ ...(options.signal !== undefined ? { signal: options.signal as AbortSignal } : {}),
92
94
  ...(body !== undefined ? { body } : {}),
93
95
  })
94
96