@repobit/dex-store-elements 1.7.3 → 2.0.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.
Files changed (227) hide show
  1. package/CHANGELOG.md +5 -913
  2. package/README.md +9 -7
  3. package/dist/src/actions/action.adapter.d.ts +36 -0
  4. package/dist/src/actions/action.adapter.js +183 -0
  5. package/dist/src/actions/action.adapter.js.map +1 -0
  6. package/dist/src/actions/action.attributes.d.ts +15 -0
  7. package/dist/src/actions/action.attributes.js +137 -0
  8. package/dist/src/actions/action.attributes.js.map +1 -0
  9. package/dist/src/actions/action.registry.d.ts +4 -0
  10. package/dist/src/actions/action.registry.js +119 -0
  11. package/dist/src/actions/action.registry.js.map +1 -0
  12. package/dist/src/actions/index.d.ts +3 -2
  13. package/dist/src/actions/index.js +3 -80
  14. package/dist/src/actions/index.js.map +1 -1
  15. package/dist/src/compute/compute.expand.d.ts +70 -0
  16. package/dist/src/compute/compute.expand.js +319 -0
  17. package/dist/src/compute/compute.expand.js.map +1 -0
  18. package/dist/src/compute/compute.scan.d.ts +54 -0
  19. package/dist/src/compute/compute.scan.js +160 -0
  20. package/dist/src/compute/compute.scan.js.map +1 -0
  21. package/dist/src/compute/option-resolver.d.ts +31 -0
  22. package/dist/src/compute/option-resolver.js +147 -0
  23. package/dist/src/compute/option-resolver.js.map +1 -0
  24. package/dist/src/contexts/context.compute.d.ts +12 -0
  25. package/dist/src/contexts/context.compute.js +4 -0
  26. package/dist/src/contexts/context.compute.js.map +1 -0
  27. package/dist/src/contexts/context.scope.d.ts +139 -0
  28. package/dist/src/contexts/context.scope.js +48 -0
  29. package/dist/src/contexts/context.scope.js.map +1 -0
  30. package/dist/src/controllers/controller.action-resolve.d.ts +110 -0
  31. package/dist/src/controllers/controller.action-resolve.js +203 -0
  32. package/dist/src/controllers/controller.action-resolve.js.map +1 -0
  33. package/dist/src/controllers/controller.action-topology.d.ts +59 -0
  34. package/dist/src/controllers/controller.action-topology.js +130 -0
  35. package/dist/src/controllers/controller.action-topology.js.map +1 -0
  36. package/dist/src/controllers/controller.compute.d.ts +72 -0
  37. package/dist/src/controllers/controller.compute.js +110 -0
  38. package/dist/src/controllers/controller.compute.js.map +1 -0
  39. package/dist/src/controllers/controller.resolve.d.ts +78 -0
  40. package/dist/src/controllers/controller.resolve.js +205 -0
  41. package/dist/src/controllers/controller.resolve.js.map +1 -0
  42. package/dist/src/core/action-topology.d.ts +62 -0
  43. package/dist/src/core/action-topology.js +67 -0
  44. package/dist/src/core/action-topology.js.map +1 -0
  45. package/dist/src/core/actions.d.ts +64 -0
  46. package/dist/src/core/actions.js +47 -0
  47. package/dist/src/core/actions.js.map +1 -0
  48. package/dist/src/core/node.element.d.ts +179 -0
  49. package/dist/src/core/node.element.js +573 -0
  50. package/dist/src/core/node.element.js.map +1 -0
  51. package/dist/src/core/node.scope.d.ts +374 -0
  52. package/dist/src/core/node.scope.js +558 -0
  53. package/dist/src/core/node.scope.js.map +1 -0
  54. package/dist/src/core/option-update.d.ts +11 -0
  55. package/dist/src/core/option-update.js +59 -0
  56. package/dist/src/core/option-update.js.map +1 -0
  57. package/dist/src/dsl/compilers/array/compiler.js +13 -14
  58. package/dist/src/dsl/compilers/array/compiler.js.map +1 -1
  59. package/dist/src/dsl/compilers/boolean/compiler.d.ts +1 -1
  60. package/dist/src/dsl/compilers/boolean/compiler.js +14 -27
  61. package/dist/src/dsl/compilers/boolean/compiler.js.map +1 -1
  62. package/dist/src/dsl/compilers/enum/compiler.js +7 -10
  63. package/dist/src/dsl/compilers/enum/compiler.js.map +1 -1
  64. package/dist/src/dsl/compilers/index.d.ts +1 -1
  65. package/dist/src/dsl/compilers/index.js +5 -5
  66. package/dist/src/dsl/compilers/index.js.map +1 -1
  67. package/dist/src/dsl/utilty.d.ts +0 -4
  68. package/dist/src/dsl/utilty.js +3 -30
  69. package/dist/src/dsl/utilty.js.map +1 -1
  70. package/dist/src/eta/batch.d.ts +123 -0
  71. package/dist/src/eta/batch.js +162 -0
  72. package/dist/src/eta/batch.js.map +1 -0
  73. package/dist/src/eta/batcher.d.ts +184 -0
  74. package/dist/src/eta/batcher.js +335 -0
  75. package/dist/src/eta/batcher.js.map +1 -0
  76. package/dist/src/eta/cache.d.ts +36 -0
  77. package/dist/src/eta/cache.js +56 -0
  78. package/dist/src/eta/cache.js.map +1 -0
  79. package/dist/src/eta/controller.d.ts +608 -0
  80. package/dist/src/eta/controller.js +1036 -0
  81. package/dist/src/eta/controller.js.map +1 -0
  82. package/dist/src/eta/factory.d.ts +74 -0
  83. package/dist/src/eta/factory.js +121 -0
  84. package/dist/src/eta/factory.js.map +1 -0
  85. package/dist/src/eta/idle.d.ts +39 -0
  86. package/dist/src/eta/idle.js +52 -0
  87. package/dist/src/eta/idle.js.map +1 -0
  88. package/dist/src/eta/index.d.ts +52 -0
  89. package/dist/src/eta/index.js +13 -0
  90. package/dist/src/eta/index.js.map +1 -0
  91. package/dist/src/eta/observer.d.ts +263 -0
  92. package/dist/src/eta/observer.js +263 -0
  93. package/dist/src/eta/observer.js.map +1 -0
  94. package/dist/src/eta/operations.d.ts +41 -0
  95. package/dist/src/eta/operations.js +59 -0
  96. package/dist/src/eta/operations.js.map +1 -0
  97. package/dist/src/eta/source-registry.d.ts +198 -0
  98. package/dist/src/eta/source-registry.js +267 -0
  99. package/dist/src/eta/source-registry.js.map +1 -0
  100. package/dist/src/eta/template-index.d.ts +285 -0
  101. package/dist/src/eta/template-index.js +642 -0
  102. package/dist/src/eta/template-index.js.map +1 -0
  103. package/dist/src/eta/types.d.ts +529 -0
  104. package/dist/src/eta/types.js +2 -0
  105. package/dist/src/eta/types.js.map +1 -0
  106. package/dist/src/index.d.ts +21 -9
  107. package/dist/src/index.js +22 -11
  108. package/dist/src/index.js.map +1 -1
  109. package/dist/src/nodes/node.context.d.ts +5 -3
  110. package/dist/src/nodes/node.context.js +48 -3
  111. package/dist/src/nodes/node.context.js.map +1 -1
  112. package/dist/src/nodes/node.option.d.ts +11 -31
  113. package/dist/src/nodes/node.option.js +158 -222
  114. package/dist/src/nodes/node.option.js.map +1 -1
  115. package/dist/src/nodes/node.product.d.ts +7 -11
  116. package/dist/src/nodes/node.product.js +85 -64
  117. package/dist/src/nodes/node.product.js.map +1 -1
  118. package/dist/src/renders/attributes/buyLink.d.ts +2 -2
  119. package/dist/src/renders/attributes/buyLink.js +14 -11
  120. package/dist/src/renders/attributes/buyLink.js.map +1 -1
  121. package/dist/src/renders/attributes/devices.d.ts +2 -2
  122. package/dist/src/renders/attributes/devices.js +9 -19
  123. package/dist/src/renders/attributes/devices.js.map +1 -1
  124. package/dist/src/renders/attributes/discount.d.ts +2 -2
  125. package/dist/src/renders/attributes/discount.js +56 -44
  126. package/dist/src/renders/attributes/discount.js.map +1 -1
  127. package/dist/src/renders/attributes/hide.d.ts +2 -2
  128. package/dist/src/renders/attributes/hide.js +49 -14
  129. package/dist/src/renders/attributes/hide.js.map +1 -1
  130. package/dist/src/renders/attributes/index.d.ts +3 -3
  131. package/dist/src/renders/attributes/index.js +2 -4
  132. package/dist/src/renders/attributes/index.js.map +1 -1
  133. package/dist/src/renders/attributes/price.d.ts +2 -2
  134. package/dist/src/renders/attributes/price.js +30 -44
  135. package/dist/src/renders/attributes/price.js.map +1 -1
  136. package/dist/src/renders/attributes/subscription.d.ts +2 -2
  137. package/dist/src/renders/attributes/subscription.js +14 -20
  138. package/dist/src/renders/attributes/subscription.js.map +1 -1
  139. package/dist/src/renders/attributes/utilty.d.ts +2 -0
  140. package/dist/src/renders/attributes/utilty.js +17 -4
  141. package/dist/src/renders/attributes/utilty.js.map +1 -1
  142. package/dist/src/renders/context.d.ts +122 -10
  143. package/dist/src/renders/context.js +140 -80
  144. package/dist/src/renders/context.js.map +1 -1
  145. package/dist/src/renders/format.js +2 -2
  146. package/dist/src/renders/format.js.map +1 -1
  147. package/dist/src/renders/index.d.ts +6 -2
  148. package/dist/src/renders/index.js +67 -29
  149. package/dist/src/renders/index.js.map +1 -1
  150. package/dist/src/renders/observe.d.ts +1 -4
  151. package/dist/src/renders/observe.js +10 -24
  152. package/dist/src/renders/observe.js.map +1 -1
  153. package/dist/src/renders/render.wrapper.d.ts +37 -0
  154. package/dist/src/renders/render.wrapper.js +147 -0
  155. package/dist/src/renders/render.wrapper.js.map +1 -0
  156. package/dist/src/templating/eta.js +1 -4
  157. package/dist/src/templating/eta.js.map +1 -1
  158. package/package.json +4 -5
  159. package/dist/src/actions/action.button.d.ts +0 -2
  160. package/dist/src/actions/action.button.js +0 -8
  161. package/dist/src/actions/action.button.js.map +0 -1
  162. package/dist/src/actions/action.input.d.ts +0 -2
  163. package/dist/src/actions/action.input.js +0 -43
  164. package/dist/src/actions/action.input.js.map +0 -1
  165. package/dist/src/actions/action.select.d.ts +0 -2
  166. package/dist/src/actions/action.select.js +0 -14
  167. package/dist/src/actions/action.select.js.map +0 -1
  168. package/dist/src/actions/utilty.d.ts +0 -16
  169. package/dist/src/actions/utilty.js +0 -149
  170. package/dist/src/actions/utilty.js.map +0 -1
  171. package/dist/src/contexts/context.datalayer.d.ts +0 -9
  172. package/dist/src/contexts/context.datalayer.js +0 -3
  173. package/dist/src/contexts/context.datalayer.js.map +0 -1
  174. package/dist/src/contexts/context.derived.d.ts +0 -12
  175. package/dist/src/contexts/context.derived.js +0 -3
  176. package/dist/src/contexts/context.derived.js.map +0 -1
  177. package/dist/src/contexts/context.option.d.ts +0 -5
  178. package/dist/src/contexts/context.option.js +0 -3
  179. package/dist/src/contexts/context.option.js.map +0 -1
  180. package/dist/src/contexts/context.product.d.ts +0 -5
  181. package/dist/src/contexts/context.product.js +0 -3
  182. package/dist/src/contexts/context.product.js.map +0 -1
  183. package/dist/src/contexts/context.state.d.ts +0 -28
  184. package/dist/src/contexts/context.state.js +0 -3
  185. package/dist/src/contexts/context.state.js.map +0 -1
  186. package/dist/src/contexts/context.store.d.ts +0 -5
  187. package/dist/src/contexts/context.store.js +0 -3
  188. package/dist/src/contexts/context.store.js.map +0 -1
  189. package/dist/src/controllers/collect-controller.d.ts +0 -39
  190. package/dist/src/controllers/collect-controller.js +0 -161
  191. package/dist/src/controllers/collect-controller.js.map +0 -1
  192. package/dist/src/controllers/compute-controller.d.ts +0 -55
  193. package/dist/src/controllers/compute-controller.js +0 -350
  194. package/dist/src/controllers/compute-controller.js.map +0 -1
  195. package/dist/src/controllers/eta-renderer/eta-dom-renderer.d.ts +0 -25
  196. package/dist/src/controllers/eta-renderer/eta-dom-renderer.js +0 -192
  197. package/dist/src/controllers/eta-renderer/eta-dom-renderer.js.map +0 -1
  198. package/dist/src/controllers/eta-renderer/eta-mutation-observer.d.ts +0 -28
  199. package/dist/src/controllers/eta-renderer/eta-mutation-observer.js +0 -206
  200. package/dist/src/controllers/eta-renderer/eta-mutation-observer.js.map +0 -1
  201. package/dist/src/controllers/eta-renderer/eta-renderer.d.ts +0 -21
  202. package/dist/src/controllers/eta-renderer/eta-renderer.js +0 -77
  203. package/dist/src/controllers/eta-renderer/eta-renderer.js.map +0 -1
  204. package/dist/src/controllers/eta-renderer/eta-template-cache.d.ts +0 -24
  205. package/dist/src/controllers/eta-renderer/eta-template-cache.js +0 -61
  206. package/dist/src/controllers/eta-renderer/eta-template-cache.js.map +0 -1
  207. package/dist/src/controllers/eta-renderer/eta-types.d.ts +0 -11
  208. package/dist/src/controllers/eta-renderer/eta-types.js +0 -1
  209. package/dist/src/controllers/eta-renderer/eta-types.js.map +0 -1
  210. package/dist/src/controllers/eta-renderer/index.d.ts +0 -2
  211. package/dist/src/controllers/eta-renderer/index.js +0 -2
  212. package/dist/src/controllers/eta-renderer/index.js.map +0 -1
  213. package/dist/src/controllers/event-pipeline-controller.d.ts +0 -28
  214. package/dist/src/controllers/event-pipeline-controller.js +0 -72
  215. package/dist/src/controllers/event-pipeline-controller.js.map +0 -1
  216. package/dist/src/events/events.d.ts +0 -75
  217. package/dist/src/events/events.js +0 -78
  218. package/dist/src/events/events.js.map +0 -1
  219. package/dist/src/nodes/node.root.d.ts +0 -16
  220. package/dist/src/nodes/node.root.js +0 -49
  221. package/dist/src/nodes/node.root.js.map +0 -1
  222. package/dist/src/nodes/node.state.d.ts +0 -88
  223. package/dist/src/nodes/node.state.js +0 -330
  224. package/dist/src/nodes/node.state.js.map +0 -1
  225. package/dist/src/renders/utility.d.ts +0 -32
  226. package/dist/src/renders/utility.js +0 -191
  227. package/dist/src/renders/utility.js.map +0 -1
@@ -0,0 +1,1036 @@
1
+ // src/eta/controller.ts
2
+ import { Task } from '@lit/task';
3
+ import { EtaMutationBatcher } from './batcher.js';
4
+ import { createEtaTemplateCache } from './cache.js';
5
+ import { createDefaultTemplateOperationFactory } from './factory.js';
6
+ import { EtaMutationObserver } from './observer.js';
7
+ import { EtaTemplateIndex } from './template-index.js';
8
+ /**
9
+ * Throws `signal.reason` when the signal has been aborted. No-op
10
+ * otherwise.
11
+ *
12
+ * Prefers the native `signal.throwIfAborted()` when available (most
13
+ * modern browsers) and falls back to a manual throw so the helper
14
+ * still works in environments that pre-date `AbortSignal`'s
15
+ * `throwIfAborted` method (notably some jsdom versions and older
16
+ * test runners).
17
+ */
18
+ function throwIfAborted(signal) {
19
+ if (typeof signal.throwIfAborted === 'function') {
20
+ signal.throwIfAborted();
21
+ return;
22
+ }
23
+ if (signal.aborted) {
24
+ throw (signal.reason ??
25
+ new DOMException('The operation was aborted.', 'AbortError'));
26
+ }
27
+ }
28
+ /**
29
+ * Reactive controller that owns the lifecycle of the Eta template
30
+ * subsystem for a single scoped boundary.
31
+ *
32
+ * Responsibilities:
33
+ *
34
+ * - Owns the `@lit/task` that drives rendering.
35
+ * - Builds the rendering context from the host-supplied `args` /
36
+ * `context` callbacks.
37
+ * - Owns the `MutationObserver` that feeds the index.
38
+ * - Owns a single `EtaTemplateIndex`.
39
+ * - Iterates operations and calls `execute(context)` on each.
40
+ * - Participates in `host.trackLocalWork` so render passes count
41
+ * toward `BdNodeElement.updateComplete`.
42
+ *
43
+ * It does NOT:
44
+ *
45
+ * - Walk the DOM during render (the index is the source of truth).
46
+ * - Discover templates during render (the factory runs once on
47
+ * `add`).
48
+ * - Know about products, options, prices, or transitions.
49
+ *
50
+ * **Skip-render semantics.** When `enabled(args)` returns `false`
51
+ * or `context(args)` returns `undefined`, the controller skips the
52
+ * render pass and returns `undefined`. Previously rendered DOM
53
+ * output stays as-is — the controller does not blank the DOM or
54
+ * remove rendered nodes. Hosts that want blank/fallback output
55
+ * should either return a context that causes templates to render
56
+ * blank values, or publish a fallback scope (e.g. an empty object
57
+ * or a sentinel value) before requesting a render. This behaviour
58
+ * is intentional: it lets the controller be safely driven from
59
+ * the host's reactive state without flickering on intermediate
60
+ * states where the scope is temporarily absent.
61
+ */
62
+ export class EtaTemplateController {
63
+ /**
64
+ * The controller's internal template cache.
65
+ *
66
+ * **Internal — true private field.** The `#` ECMAScript private
67
+ * field cannot be observed from outside the class at all
68
+ * (neither via property lookup nor via casting). The TypeScript
69
+ * `private` modifier above would be enough for the type checker
70
+ * but JS property access would still find the field; using
71
+ * `#cache` closes that gap at runtime too.
72
+ *
73
+ * **Per-controller isolation.** The controller always builds its
74
+ * own cache; there is no public option to inject a different
75
+ * one. The cache is shared with the controller's private
76
+ * factory so `clearCache()` drains whatever the factory uses to
77
+ * memoize compiled templates.
78
+ *
79
+ * Hosts invalidate the controller's own cache via
80
+ * `controller.clearCache()`. The `clearCache()` call only clears
81
+ * this controller's cache; no other state is affected.
82
+ */
83
+ #cache;
84
+ constructor(host, options) {
85
+ /**
86
+ * Set of every shadow root that has already been wired into a
87
+ * shadow binding.
88
+ *
89
+ * `WeakSet` because shadow roots can be GC'd when their host
90
+ * disconnects — we never want to keep them alive through this
91
+ * field. `_addShadowBinding` is the single writer; both
92
+ * `_connectShadowRoots` (initial discovery) and
93
+ * `_discoverShadowRootsFor` (light-DOM mutation-driven
94
+ * discovery) gate registration through this set so the same
95
+ * shadow root is never wired twice.
96
+ */
97
+ this._seenShadows = new WeakSet();
98
+ /**
99
+ * Per-shadow-root observer + batcher + index triples. Empty
100
+ * when the host does not enumerate any shadow roots. Each
101
+ * triple is wired in `hostConnected` and torn down in
102
+ * `hostDisconnected` alongside the light-DOM observer.
103
+ */
104
+ this.shadowBindings = [];
105
+ this.host = host;
106
+ // Runtime guard — `EtaTemplateHost.root` is non-optional in
107
+ // the type system, but a host implementing the interface
108
+ // could still hand back `undefined` at runtime. The index
109
+ // uses `host.root` as its discovery boundary and silently
110
+ // indexing the whole document if `root` is missing would
111
+ // produce very confusing behaviour. Fail loudly instead.
112
+ if (!host.root) {
113
+ throw new Error('EtaTemplateController: host.root is required. ' +
114
+ 'The host element must expose a `root` getter that ' +
115
+ 'returns the Element the controller should index.');
116
+ }
117
+ this.argsFn = options.args;
118
+ this.enabledFn = options.enabled;
119
+ this.contextFn = options.context;
120
+ this.onRenderError = options.onRenderError;
121
+ this.mutationBatchScheduleOption =
122
+ options.mutationBatchSchedule ?? 'animation-frame';
123
+ this._boundaryElementPredicate =
124
+ options.isBoundaryElement;
125
+ this.#cache = createEtaTemplateCache();
126
+ // The controller owns its factory. There is no public option
127
+ // to inject one — the factory is a private collaborator like
128
+ // the cache. The controller builds a default
129
+ // `DefaultTemplateOperationFactory` that shares its `#cache`,
130
+ // so the cache and the factory stay in lockstep and
131
+ // `clearCache()` drains the factory's compiled-template store
132
+ // as well.
133
+ const normalizedAttributeNames = this._observerAttributeNames(options);
134
+ this._normalizedAttributeNames = normalizedAttributeNames;
135
+ this.factory =
136
+ createDefaultTemplateOperationFactory(this.#cache, this._factoryOptions(options, normalizedAttributeNames));
137
+ this.index =
138
+ options.createIndex?.(this.factory) ??
139
+ new EtaTemplateIndex(this.factory, {
140
+ root: host.root,
141
+ isBoundaryElement: options.isBoundaryElement
142
+ });
143
+ // Round-6 mutation-batching layer. The batcher sits between
144
+ // the observer and the index: the observer forwards raw
145
+ // MutationRecord batches via `onRecords`, the batcher
146
+ // accumulates them, compacts them per the design rules, and
147
+ // flushes the compacted mutations to the index on the
148
+ // configured schedule (`animation-frame` by default).
149
+ //
150
+ // The `onFlush` callback kicks a render when the flush was
151
+ // dirty — empty flushes never cause a render. We deliberately
152
+ // call `task.run()` (not `requestRender()`) because the
153
+ // batcher is the source of truth for "dirty" and we want the
154
+ // Task's scheduling semantics (abort, dedupe, re-entry)
155
+ // rather than the manual `requestRender()` path.
156
+ this.batcher = new EtaMutationBatcher(this.index, {
157
+ schedule: () => this.mutationBatchSchedule,
158
+ onFlush: (dirty) => {
159
+ if (dirty) {
160
+ this.task.run();
161
+ }
162
+ }
163
+ });
164
+ this.observer = new EtaMutationObserver({
165
+ attributeNames: normalizedAttributeNames,
166
+ onRecords: (records) => {
167
+ this.batcher.enqueue(records);
168
+ // Discovery hook: a freshly-added element may carry a
169
+ // shadow root that did not exist when `_connectShadowRoots`
170
+ // ran. The hook is idempotent (gated by `_seenShadows`)
171
+ // and only walks `childList` records so attribute / text
172
+ // mutations stay cheap.
173
+ this._discoverShadowRootsFor(records);
174
+ }
175
+ });
176
+ this.task = new Task(host, {
177
+ autoRun: true,
178
+ argsEqual: options.argsEqual,
179
+ task: async (args, { signal }) => {
180
+ const work = this._runRenderPass(args, signal);
181
+ const tracked = this.host.trackLocalWork?.(this, work) ?? work;
182
+ return tracked;
183
+ },
184
+ args: () => this.argsFn()
185
+ });
186
+ host.addController(this);
187
+ }
188
+ /**
189
+ * Invalidates every compiled template this controller owns.
190
+ *
191
+ * Drops all entries from the controller's internal cache. The
192
+ * next render that touches a previously-compiled template will
193
+ * re-compile it through the cache.
194
+ *
195
+ * **Per-controller scope.** `clearCache()` only clears this
196
+ * controller's own cache. There is no public cache injection
197
+ * option; hosts that want shared templates across controllers
198
+ * should compile and store those templates at the application
199
+ * layer instead of sharing the controller's internal Eta cache.
200
+ *
201
+ * Safe to call at any time, including between renders.
202
+ */
203
+ clearCache() {
204
+ this.#cache.clear();
205
+ }
206
+ /**
207
+ * Status of the underlying Task.
208
+ *
209
+ * Exposed so consumers (e.g. tests) can assert progress without
210
+ * reaching into the `@lit/task` surface.
211
+ */
212
+ get status() {
213
+ return this.task.status;
214
+ }
215
+ /**
216
+ * Latest context produced by a successful render pass.
217
+ */
218
+ get value() {
219
+ return this.task.value;
220
+ }
221
+ /**
222
+ * Most recent render error, or `undefined` when the last render
223
+ * succeeded.
224
+ *
225
+ * Mirrors `@lit/task`'s `Task.error` getter. When a host passes
226
+ * `onRenderError`, the controller swallows render errors and
227
+ * records them here — read this getter to surface them in UI
228
+ * state (e.g. an error banner). When `onRenderError` is omitted,
229
+ * render errors reject `updateComplete`; both this getter and
230
+ * the rejected promise expose the same error value.
231
+ */
232
+ get error() {
233
+ return this.task.error;
234
+ }
235
+ /**
236
+ * Promise that resolves when the current render pass completes.
237
+ *
238
+ * Mirrors `BdNodeElement.updateComplete`'s promise semantics for
239
+ * the Eta subsystem. When the host supplies `trackLocalWork`,
240
+ * this promise is tracked under the controller's instance so the
241
+ * host's `updateComplete` waits for the render to settle before
242
+ * resolving.
243
+ *
244
+ * Useful for tests and for downstream consumers that need to
245
+ * observe render completion without poking at `@lit/task`.
246
+ */
247
+ get updateComplete() {
248
+ return this.task.taskComplete;
249
+ }
250
+ /**
251
+ * Requests a fresh render pass through the underlying `@lit/task`.
252
+ *
253
+ * Most consumers never need to call this: the controller re-runs
254
+ * whenever the host signals an update, the index mutates, or the
255
+ * Task args change. The hook is provided for tests and for
256
+ * consumers that want to force a re-render after an external cache
257
+ * invalidation, scope mutation outside the normal reactive flow,
258
+ * or other event that the Task's `argsEqual` check might miss.
259
+ *
260
+ * The name reflects what actually happens: `Task.run()` schedules
261
+ * the task through Task's own scheduling semantics (which may
262
+ * batch, dedupe, or abort against the current run id) rather than
263
+ * synchronously executing the render loop.
264
+ *
265
+ * **Returns a promise.** The promise resolves to the latest
266
+ * context (`TContext | undefined`) produced by the render pass
267
+ * that `Task.run()` scheduled. Awaiting it lets tests and
268
+ * downstream consumers observe render completion without
269
+ * reaching into `@lit/task`. The promise resolves with the
270
+ * controller's `value`, which is `undefined` when the render was
271
+ * skipped (because `enabled()` returned `false` or `context()`
272
+ * returned `undefined`).
273
+ *
274
+ * Because `Task.run()` returns `Promise<void>` in the installed
275
+ * `@lit/task` version, this method kicks the run and then waits
276
+ * on `taskComplete` (the same promise exposed as
277
+ * `controller.updateComplete`) before reading `this.value`.
278
+ * `Task.run()` resolves when the run is *scheduled*, not when it
279
+ * *completes*; waiting on `taskComplete` is the only way to
280
+ * guarantee the returned context reflects the render that this
281
+ * call kicked off. The same `taskComplete` is also what the host
282
+ * `updateComplete` waits on (through `trackLocalWork`), so
283
+ * awaiting this method aligns the caller with the host's
284
+ * subtree-completion promise.
285
+ */
286
+ async requestRender() {
287
+ // Kick the Task run. The returned promise resolves when the
288
+ // run is scheduled, NOT when the render completes.
289
+ this.task.run();
290
+ // Wait for the render to actually finish so `this.value`
291
+ // reflects this render's context rather than the previous
292
+ // run's value. `taskComplete` is the same promise exposed
293
+ // through the controller's `updateComplete` getter.
294
+ await this.task.taskComplete;
295
+ return this.value;
296
+ }
297
+ /**
298
+ * Flushes the mutation batcher synchronously.
299
+ *
300
+ * Forwards any pending `MutationRecord`s to the index through
301
+ * the batcher's compaction pass and returns whether the flush
302
+ * made the index dirty. Returns `false` when there was nothing
303
+ * to flush — an empty batch is a no-op.
304
+ *
305
+ * **Use cases:**
306
+ *
307
+ * - Hosts that drive the controller with
308
+ * `mutationBatchSchedule: 'manual'` and want to apply a
309
+ * known decoration phase's mutations before reading the
310
+ * index. Pair with `flushAndRender()` when a render is
311
+ * needed afterwards.
312
+ * - Tests that want a deterministic, intent-revealing way to
313
+ * drain the batcher without going through the observer's
314
+ * scheduled tick. This is the recommended way to assert on
315
+ * the post-flush index state from test bodies.
316
+ * - Hot-path code that knows a render is about to be requested
317
+ * via `requestRender()` and wants to guarantee the render
318
+ * sees the latest mutations (the render task body already
319
+ * calls `batcher.flush()` before executing, so this is
320
+ * rarely needed in practice — but useful when callers want
321
+ * the flush to happen synchronously at the call site rather
322
+ * than inside the Task).
323
+ *
324
+ * Does NOT call `onFlush` on the batcher, so the flush never
325
+ * re-enters `task.run()` on its own. Call `flushAndRender()`
326
+ * when a render should follow the flush.
327
+ *
328
+ * Returns the batcher's `flush()` return value — `true` when
329
+ * at least one compacted mutation made the index dirty,
330
+ * `false` otherwise. The dirty flag mirrors the batcher's
331
+ * dirty semantics (OR-accumulated across the four compaction
332
+ * passes — see `EtaMutationBatcher.flush`).
333
+ */
334
+ /**
335
+ * Flushes every observer queue into its matching batcher, then
336
+ * flushes every batcher into its matching index.
337
+ *
338
+ * Forwards any pending `MutationRecord`s to the index through
339
+ * the batcher's compaction pass and returns whether the flush
340
+ * made the index dirty. Returns `false` when there was nothing
341
+ * to flush — an empty batch is a no-op.
342
+ *
343
+ * **Manual schedule mode.** When `mutationBatchSchedule` is
344
+ * `'manual'`, the batcher never auto-flushes; the host must
345
+ * drain the queue itself. This method honours that contract
346
+ * across every observed root (light + every shadow binding).
347
+ *
348
+ * **Drain order:**
349
+ *
350
+ * 1. Light observer → light batcher
351
+ * (`observer.flushPendingRecords({ notify: false })`).
352
+ * 2. Every shadow observer → its shadow batcher
353
+ * (`binding.observer.flushPendingRecords({ notify: false })`).
354
+ * Drain the observer BEFORE the batcher so records the
355
+ * browser has produced but not yet delivered reach the
356
+ * batcher before the synchronous flush.
357
+ * 3. Light batcher → light index (`batcher.flush()`).
358
+ * 4. Every shadow batcher → its shadow index
359
+ * (`binding.batcher.flush()`), OR-accumulated into the
360
+ * combined dirty flag.
361
+ *
362
+ * **Use cases:**
363
+ *
364
+ * - Hosts that drive the controller with
365
+ * `mutationBatchSchedule: 'manual'` and want to apply a
366
+ * known decoration phase's mutations before reading the
367
+ * index. Pair with `flushAndRender()` when a render is
368
+ * needed afterwards.
369
+ * - Tests that want a deterministic, intent-revealing way to
370
+ * drain the batcher without going through the observer's
371
+ * scheduled tick. This is the recommended way to assert on
372
+ * the post-flush index state from test bodies.
373
+ * - Hot-path code that knows a render is about to be requested
374
+ * via `requestRender()` and wants to guarantee the render
375
+ * sees the latest mutations (the render task body already
376
+ * calls `batcher.flush()` before executing, so this is
377
+ * rarely needed in practice — but useful when callers want
378
+ * the flush to happen synchronously at the call site rather
379
+ * than inside the Task).
380
+ *
381
+ * Does NOT call `onFlush` on any batcher, so the flush never
382
+ * re-enters `task.run()` on its own. Call `flushAndRender()`
383
+ * when a render should follow the flush.
384
+ *
385
+ * Returns the combined dirty flag — `true` when at least one
386
+ * batcher (light or shadow) made its index dirty, `false`
387
+ * otherwise. The dirty flag mirrors each batcher's dirty
388
+ * semantics (OR-accumulated across the four compaction passes
389
+ * — see `EtaMutationBatcher.flush`).
390
+ *
391
+ * Idempotent — calling on an empty queue/batch still returns
392
+ * `false` and never triggers a render. The `notify: false` flag
393
+ * on every `flushPendingRecords` call keeps the observer's
394
+ * own `onChange` callback dormant during a manual flush so a
395
+ * host that drains and then calls `requestRender()` does not
396
+ * double-schedule.
397
+ */
398
+ flushMutations() {
399
+ this.observer.flushPendingRecords({ notify: false });
400
+ for (const binding of this.shadowBindings) {
401
+ binding.observer.flushPendingRecords({ notify: false });
402
+ }
403
+ let dirty = this.batcher.flush();
404
+ for (const binding of this.shadowBindings) {
405
+ if (binding.batcher.flush()) {
406
+ dirty = true;
407
+ }
408
+ }
409
+ return dirty;
410
+ }
411
+ /**
412
+ * Flushes the mutation batcher and requests a fresh render
413
+ * pass when the flush was dirty.
414
+ *
415
+ * Convenience wrapper for the "manual" schedule mode — hosts
416
+ * that opt into `mutationBatchSchedule: 'manual'` must drain
417
+ * the batcher themselves and then trigger a render. This
418
+ * method collapses both steps into one call:
419
+ *
420
+ * ```ts
421
+ * decorateBlock(block);
422
+ * await decorateIcons(block);
423
+ * eta.flushAndRender();
424
+ * ```
425
+ *
426
+ * When the flush was a no-op (`false` return from the
427
+ * batcher) the render is skipped — there is no point re-running
428
+ * the Task against an unchanged index. When the flush was
429
+ * dirty, the call forwards to `requestRender()` so the Task
430
+ * runs with the same scheduling semantics as any other
431
+ * render-triggering path.
432
+ *
433
+ * Does NOT wait for the render to complete; callers that need
434
+ * to await the resulting context can follow up with
435
+ * `await controller.updateComplete` (or
436
+ * `await controller.requestRender()`). The return value of
437
+ * the underlying `requestRender()` is intentionally discarded
438
+ * to keep the API synchronous and easy to call from
439
+ * imperative decoration flows.
440
+ */
441
+ flushAndRender() {
442
+ const dirty = this.flushMutations();
443
+ if (dirty) {
444
+ // `requestRender()` returns a promise that resolves once
445
+ // the Task completes; we intentionally discard it so the
446
+ // host can call `flushAndRender()` from synchronous
447
+ // decoration code. Callers that need to await the render
448
+ // can use `await controller.updateComplete` afterwards.
449
+ void this.requestRender();
450
+ }
451
+ }
452
+ /**
453
+ * Reads the current batch schedule as resolved by the
454
+ * controller. Useful for diagnostics and for tests that want
455
+ * to assert on the controller's effective schedule without
456
+ * reaching into the private `batcher` field.
457
+ */
458
+ get mutationBatchSchedule() {
459
+ return this._currentMutationBatchSchedule();
460
+ }
461
+ _currentMutationBatchSchedule() {
462
+ const schedule = this.mutationBatchScheduleOption;
463
+ if (typeof schedule === 'function') {
464
+ return schedule() ?? 'animation-frame';
465
+ }
466
+ return schedule ?? 'animation-frame';
467
+ }
468
+ /**
469
+ * Runs a single render pass.
470
+ *
471
+ * The pass is gated by `enabled(args)` and `context(args)`:
472
+ *
473
+ * - `enabled` returning `false` skips the loop and returns
474
+ * `undefined` (no context, no render).
475
+ * - `context` returning `undefined` does the same.
476
+ *
477
+ * When both gates pass, the render loop runs in two phases:
478
+ *
479
+ * 1. **Pre-flush.** The mutation batcher applies any pending
480
+ * `MutationRecord` batches to the index via
481
+ * `observer.flushPendingRecords({ notify: false })` drains
482
+ * records the browser has produced but not delivered yet into
483
+ * the batcher, then `batcher.flush()` applies the compacted
484
+ * batch so the index and source registry are accurate before
485
+ * the render reads operations. Without this step, a render
486
+ * triggered by a Task args change or a manual `requestRender()`
487
+ * call could run against a stale index whenever the observer or
488
+ * batcher had not yet flushed a pending burst of DOM changes.
489
+ *
490
+ * `batcher.flush()` cancels any pending scheduled flush
491
+ * AND applies the current batch synchronously; it does NOT
492
+ * call `onFlush`. That detail is important — calling
493
+ * `onFlush` from inside an in-flight render pass would
494
+ * re-enter `task.run()` and create an infinite render
495
+ * loop. The Task's auto-run and the batcher's onFlush
496
+ * callback are kept on separate scheduling paths for this
497
+ * reason.
498
+ *
499
+ * The round-6 batcher sits between the observer and the index,
500
+ * so both layers participate in the pre-flush: the observer
501
+ * drains queued records into the batcher, and the batcher
502
+ * drains its compacted mutations into the index.
503
+ * 2. **Render.** The render loop runs in two phases so each
504
+ * phase can suspend only the observer that would otherwise
505
+ * treat the phase's self-induced mutations as fresh external
506
+ * mutations:
507
+ *
508
+ * a. **Light-DOM phase.** Iterates `this.index` inside
509
+ * `observer.suspendSync(...)` so DOM writes performed by
510
+ * light-DOM operations are not fed back as fresh
511
+ * mutations. The `finally` block drains whatever's queued
512
+ * after the loop — at this point only self-induced
513
+ * records should remain.
514
+ * b. **Shadow phase.** Iterates each binding's `index`
515
+ * inside `binding.observer.suspendSync(...)`. Wrapping
516
+ * the shadow phase in `this.observer.suspendSync` would
517
+ * discard legitimate external light-DOM mutations queued
518
+ * in the meantime via `takeRecords`, and would not
519
+ * actually suppress shadow observer feedback (the light
520
+ * observer cannot see shadow-tree writes anyway). Each
521
+ * binding therefore suspends its OWN observer so writes
522
+ * inside its shadow root are not fed back as fresh
523
+ * mutations.
524
+ *
525
+ * The pre-flush step above drained every shadow observer
526
+ * BEFORE either phase, so the only records the shadow
527
+ * observer will see during the shadow phase are writes from
528
+ * the shadow operations themselves — exactly what each
529
+ * shadow observer's `suspendSync` is there to suppress.
530
+ *
531
+ * Per-operation errors are routed to `onRenderError` when
532
+ * supplied; otherwise they propagate to the Task and abort the
533
+ * render pass.
534
+ */
535
+ async _runRenderPass(args, signal) {
536
+ if (this.enabledFn && !this.enabledFn(args)) {
537
+ return undefined;
538
+ }
539
+ const context = this.contextFn(args);
540
+ if (context === undefined) {
541
+ return undefined;
542
+ }
543
+ // Pre-flush: drain queued observer records into the batcher,
544
+ // then apply any pending batch mutations to the index BEFORE
545
+ // building the render loop. Without this step, the render
546
+ // would run against a stale index whenever a burst of DOM
547
+ // changes has been produced by the observer or accumulated by
548
+ // the batcher but not yet flushed.
549
+ //
550
+ // `batcher.flush()` cancels any pending scheduled flush and
551
+ // applies the current batch synchronously. It does NOT call
552
+ // `onFlush` (no re-entrant `task.run()` from inside an
553
+ // in-flight render pass — see the batcher's flush vs
554
+ // flushAndNotify split).
555
+ //
556
+ // Draining the observer first matters because suspendSync's
557
+ // final takeRecords() intentionally drops self-induced render
558
+ // records. External records queued before the render must reach
559
+ // the batcher before that drain happens. The same applies to
560
+ // every shadow observer — drain those too so render passes
561
+ // don't run against stale shadow indices.
562
+ this.observer.flushPendingRecords({ notify: false });
563
+ this.batcher.flush();
564
+ for (const binding of this.shadowBindings) {
565
+ binding.observer.flushPendingRecords({ notify: false });
566
+ binding.batcher.flush();
567
+ }
568
+ this.observer.suspendSync(() => {
569
+ for (const op of this.index) {
570
+ if (signal.aborted) {
571
+ break;
572
+ }
573
+ try {
574
+ op.execute(context);
575
+ }
576
+ catch (error) {
577
+ if (this.onRenderError) {
578
+ this.onRenderError(error, op);
579
+ }
580
+ else {
581
+ throw error;
582
+ }
583
+ }
584
+ }
585
+ });
586
+ // Shadow operations run under their OWN observer suspension.
587
+ // The render loop iterates light-DOM ops and shadow-binding
588
+ // ops in separate phases so each phase can suspend only the
589
+ // observer that would otherwise treat the phase's self-induced
590
+ // mutations as fresh external mutations.
591
+ //
592
+ // Why two phases:
593
+ //
594
+ // - The light-DOM observer stays alive while a shadow
595
+ // operation writes its rendered value into a shadow node —
596
+ // that observer cannot see the write (different root), so
597
+ // it cannot feed back into the light index. Wrapping the
598
+ // shadow phase in `this.observer.suspendSync` would be a
599
+ // no-op (and would discard legitimate external light-DOM
600
+ // mutations queued in the meantime via `takeRecords`).
601
+ // - The shadow observer for a binding sees only mutations
602
+ // inside its own shadow root. Shadow operations writing
603
+ // into that root must run under that observer's
604
+ // suspension so the writes are not fed back as fresh
605
+ // shadow mutations; otherwise the shadow batcher would
606
+ // re-enqueue them on the next flush and the operation
607
+ // would rebuild against no template and get dropped (the
608
+ // shadow source registry has already persisted the
609
+ // rendered plain string).
610
+ //
611
+ // Each phase reuses the existing abort-signal check and the
612
+ // `onRenderError` routing so a failing operation only fails
613
+ // its own phase.
614
+ for (const binding of this.shadowBindings) {
615
+ binding.observer.suspendSync(() => {
616
+ for (const op of binding.index) {
617
+ if (signal.aborted) {
618
+ break;
619
+ }
620
+ try {
621
+ op.execute(context);
622
+ }
623
+ catch (error) {
624
+ if (this.onRenderError) {
625
+ this.onRenderError(error, op);
626
+ }
627
+ else {
628
+ throw error;
629
+ }
630
+ }
631
+ }
632
+ });
633
+ }
634
+ // Surface aborts that fired during the render loop as Task
635
+ // errors so the task moves to the `ABORTED` status instead of
636
+ // returning a stale context. The render loop itself is
637
+ // synchronous, but external callers (e.g. host disconnection)
638
+ // can flip the signal between the loop's `break` check and the
639
+ // point where we return.
640
+ throwIfAborted(signal);
641
+ return context;
642
+ }
643
+ /**
644
+ * Returns the effective attribute-name list that both the
645
+ * observer and the factory must agree on.
646
+ *
647
+ * The single source of truth avoids contradictory
648
+ * configurations where the observer is told to watch every
649
+ * attribute while the factory is told to filter to a specific
650
+ * list (or vice versa). The previous behaviour dropped records
651
+ * on the floor silently when `observeAllAttributes: true` was
652
+ * combined with a non-empty `attributeNames`.
653
+ *
654
+ * Resolution order:
655
+ *
656
+ * 1. `observeAllAttributes: true` → `undefined` (watch
657
+ * everything).
658
+ * 2. Non-empty `attributeNames` → that exact list.
659
+ * 3. Anything else → `undefined` (watch everything).
660
+ */
661
+ _observerAttributeNames(options) {
662
+ if (options.observeAllAttributes === true) {
663
+ return undefined;
664
+ }
665
+ if (options.attributeNames &&
666
+ options.attributeNames.length > 0) {
667
+ return options.attributeNames;
668
+ }
669
+ return undefined;
670
+ }
671
+ /**
672
+ * Builds the per-factory option object passed to the default
673
+ * factory constructor.
674
+ *
675
+ * `observeAllAttributes: true` (or no allowlist supplied) means
676
+ * the factory should observe every attribute, so we omit
677
+ * `attributeNames` entirely and let the factory default to
678
+ * "observe all".
679
+ *
680
+ * When the controller was constructed with an explicit
681
+ * `attributeNames` allowlist, we forward the **normalized** list
682
+ * (computed by `_observerAttributeNames`) so the factory and the
683
+ * observer agree on the same allowlist. Using the raw
684
+ * `options.attributeNames` here would let `observeAllAttributes:
685
+ * true` coexist with a non-empty list, contradicting the
686
+ * observer's "watch everything" mode.
687
+ */
688
+ _factoryOptions(_options, normalizedAttributeNames) {
689
+ if (normalizedAttributeNames === undefined) {
690
+ return {};
691
+ }
692
+ return { attributeNames: normalizedAttributeNames };
693
+ }
694
+ hostConnected() {
695
+ // Initialize after the host is connected so the light DOM is available.
696
+ // Nested boundaries are detected by the configured isBoundaryElement predicate.
697
+ this.index.initialize(this.host.root);
698
+ this.observer.observe(this.host.root);
699
+ // Shadow roots are wired after the light-DOM observer so a
700
+ // render pass covers both trees in stable order. The host's
701
+ // `shadowRoots` callback runs once per connect; closed roots
702
+ // are skipped with a one-time console.warn.
703
+ this._connectShadowRoots();
704
+ }
705
+ /**
706
+ * Discovers every open shadow root attached to a descendant of
707
+ * the host's light subtree and wires one observer + batcher +
708
+ * index triple per root.
709
+ *
710
+ * `MutationObserver` is scoped to a single root: an observer
711
+ * attached to the host's light root does NOT see mutations
712
+ * inside any nested shadow root. To render templates into
713
+ * shadow trees the controller must therefore attach a separate
714
+ * observer per shadow root. The controller discovers those roots
715
+ * itself — walking the host's light subtree and reading
716
+ * `element.shadowRoot` — so hosts do not have to plumb the
717
+ * list through.
718
+ *
719
+ * Closed shadow roots are unreachable from outside (the
720
+ * `shadowRoot` getter returns `null` for closed mode), so they
721
+ * are silently skipped. Hosts that want to render into a
722
+ * closed root should attach a controller from inside that root
723
+ * instead.
724
+ *
725
+ * Idempotent — re-running on a second `hostConnected()` first
726
+ * tears down any existing bindings, then rebuilds them.
727
+ */
728
+ _connectShadowRoots() {
729
+ if (this.shadowBindings.length > 0) {
730
+ this._disconnectShadowRoots();
731
+ }
732
+ const root = this.host.root;
733
+ if (root === undefined) {
734
+ return;
735
+ }
736
+ // Iterative DFS over both the light DOM and any attached open
737
+ // shadow roots. Each step pops a node from the stack; for
738
+ // light-DOM elements we descend into `children` AND follow
739
+ // `shadowRoot` so a shadow tree nested inside a child is
740
+ // discovered. For shadow roots we descend into the root's
741
+ // own children (which are the shadow-tree's top-level
742
+ // nodes). Iterative (not recursive) so deeply nested
743
+ // shadow trees cannot blow the stack.
744
+ const stack = [root];
745
+ while (stack.length > 0) {
746
+ const node = stack.pop();
747
+ if (node instanceof ShadowRoot) {
748
+ // Register the shadow root through the shared helper —
749
+ // it gates against `_seenShadows` so the same shadow root
750
+ // is never wired twice (the same guard also catches a
751
+ // re-entrant `_addShadowBinding` from a nested element's
752
+ // own upgrade path).
753
+ this._addShadowBinding(node);
754
+ // Descend into the shadow tree's top-level children.
755
+ for (const child of Array.from(node.children)) {
756
+ stack.push(child);
757
+ }
758
+ continue;
759
+ }
760
+ if (node instanceof Element) {
761
+ // Descend into light-DOM children first.
762
+ for (const child of Array.from(node.children)) {
763
+ stack.push(child);
764
+ }
765
+ // Then follow any attached open shadow root. Order
766
+ // matters for stable enumeration — children first,
767
+ // then the shadow — so the outer widget's own shadow
768
+ // tree appears before an inner widget's shadow tree.
769
+ if (node.shadowRoot) {
770
+ stack.push(node.shadowRoot);
771
+ }
772
+ }
773
+ }
774
+ }
775
+ /**
776
+ * Registers a single shadow root with the controller — builds
777
+ * one observer + batcher + index triple per root and wires it
778
+ * into the light-DOM observer's discovery callback.
779
+ *
780
+ * Gates registration through `_seenShadows` so the same shadow
781
+ * root is never wired twice. Closed roots are skipped
782
+ * silently (their `shadowRoot` getter returns `null` so the
783
+ * caller should not pass them in).
784
+ */
785
+ _addShadowBinding(shadow) {
786
+ if (this._seenShadows.has(shadow)) {
787
+ return;
788
+ }
789
+ this._seenShadows.add(shadow);
790
+ const index = new EtaTemplateIndex(this.factory, {
791
+ root: shadow,
792
+ isBoundaryElement: this._shadowBoundaryPredicate(shadow)
793
+ });
794
+ const batcher = new EtaMutationBatcher(index, {
795
+ schedule: () => this.mutationBatchSchedule,
796
+ onFlush: (dirty) => {
797
+ if (dirty) {
798
+ this.task.run();
799
+ }
800
+ }
801
+ });
802
+ const observer = new EtaMutationObserver({
803
+ attributeNames: this._normalizedAttributeNames,
804
+ onRecords: (records) => {
805
+ batcher.enqueue(records);
806
+ }
807
+ });
808
+ index.initialize(shadow);
809
+ observer.observe(shadow);
810
+ this.shadowBindings.push({
811
+ shadow,
812
+ observer,
813
+ batcher,
814
+ index
815
+ });
816
+ }
817
+ /**
818
+ * Reacts to a light-DOM `MutationRecord` batch by registering
819
+ * any newly-discovered open shadow roots.
820
+ *
821
+ * Lit `until` directives, `repeat` expansions, and custom
822
+ * element upgrades can attach shadow roots after the
823
+ * controller's `hostConnected()` pass has already walked the
824
+ * subtree. The light-DOM observer's callback fires for every
825
+ * mutation batch — we hook it here so a newly-inserted element
826
+ * that carries an open shadow root gets wired into the
827
+ * controller on the next batcher tick.
828
+ *
829
+ * Idempotent — `_addShadowBinding` gates against
830
+ * `_seenShadows`, so re-running the discovery walk on the same
831
+ * records (or running it during a `suspendSync` of the light
832
+ * observer) is harmless.
833
+ *
834
+ * Only `childList` records matter: an `add` of an element is
835
+ * what can carry a fresh shadow root, and skipping text /
836
+ * attribute records keeps the discovery walk cheap.
837
+ */
838
+ _discoverShadowRootsFor(records) {
839
+ for (const record of records) {
840
+ if (record.type !== 'childList') {
841
+ continue;
842
+ }
843
+ for (const added of Array.from(record.addedNodes)) {
844
+ this._walkForShadows(added);
845
+ }
846
+ }
847
+ }
848
+ /**
849
+ * Iterative DFS over `node`'s subtree (light children + open
850
+ * shadow roots) that registers every shadow root it encounters
851
+ * via `_addShadowBinding`.
852
+ *
853
+ * Mirrors the discovery shape used inside `_connectShadowRoots`
854
+ * but operates on a single subtree rooted at `node` rather
855
+ * than the full host subtree. Skips detached roots and any
856
+ * element/shadow already known to the controller — the
857
+ * `_seenShadows` gate inside `_addShadowBinding` short-circuits
858
+ * the second registration.
859
+ */
860
+ _walkForShadows(node) {
861
+ const stack = [node];
862
+ while (stack.length > 0) {
863
+ const current = stack.pop();
864
+ if (current instanceof ShadowRoot) {
865
+ this._addShadowBinding(current);
866
+ // Descend into the shadow tree's top-level children —
867
+ // a nested host's own shadow must also be picked up.
868
+ for (const child of Array.from(current.children)) {
869
+ stack.push(child);
870
+ }
871
+ continue;
872
+ }
873
+ if (current instanceof Element) {
874
+ for (const child of Array.from(current.children)) {
875
+ stack.push(child);
876
+ }
877
+ const shadow = current.shadowRoot;
878
+ // `shadowRoot` is `null` for closed-mode and for
879
+ // elements that have not yet created a shadow root. Open
880
+ // roots are reachable via the getter and have a
881
+ // non-null return — `_addShadowBinding` then gates
882
+ // against `_seenShadows`.
883
+ if (shadow !== null) {
884
+ stack.push(shadow);
885
+ }
886
+ }
887
+ }
888
+ }
889
+ /**
890
+ * Tears down every per-shadow-root observer, batcher, and index.
891
+ * Mirrors the light-DOM teardown order in `hostDisconnected()`
892
+ * so source-registry preservation behaves the same way on
893
+ * shadow roots as it does on the light tree.
894
+ *
895
+ * Clears `_seenShadows` so a subsequent `hostConnected()` can
896
+ * re-discover every shadow root from scratch — observers do
897
+ * not survive disconnect, so previously-seen shadow roots are
898
+ * no longer wired and should be re-discovered.
899
+ */
900
+ _disconnectShadowRoots() {
901
+ for (const binding of this.shadowBindings) {
902
+ binding.observer.flushPendingRecords({ notify: false });
903
+ binding.batcher.flush();
904
+ binding.observer.disconnect();
905
+ binding.index.disposeAll();
906
+ }
907
+ this.shadowBindings = [];
908
+ // Rebuild the seen-set to match the cleared bindings list.
909
+ // `WeakSet` cannot be cleared so we reassign the field —
910
+ // any other code holding a reference to the previous set
911
+ // would still see stale entries, but the controller is the
912
+ // sole writer and the only other read site is
913
+ // `_addShadowBinding`, which always reads the latest field
914
+ // value. The `unknown` cast is needed because the field is
915
+ // declared `private readonly` — the same pattern is used by
916
+ // the existing `WeakMap` / `WeakSet` resets in
917
+ // `EtaTemplateIndex.clear()`.
918
+ this._seenShadows = new WeakSet();
919
+ }
920
+ /**
921
+ * Returns the boundary predicate for a shadow-root index. The
922
+ * controller currently reuses the host's
923
+ * `isBoundaryElement` rule for every observed root — the host
924
+ * option is the single source of truth. This helper exists so
925
+ * per-root overrides can be added later without churning the
926
+ * `_connectShadowRoots` call site.
927
+ *
928
+ * Returns `undefined` when the host did not supply an
929
+ * `isBoundaryElement` predicate — preserves the pre-fix
930
+ * behaviour for those hosts (the shadow index then has no
931
+ * boundary rule and falls back to its own ownership checks).
932
+ */
933
+ _shadowBoundaryPredicate(_shadow) {
934
+ return this._boundaryElementPredicate;
935
+ }
936
+ hostDisconnected() {
937
+ // Round-6 teardown order. The order matters for source-
938
+ // registry preservation:
939
+ //
940
+ // 1. `observer.flushPendingRecords({ notify: false })` —
941
+ // drains any records the browser has already produced
942
+ // for the underlying `MutationObserver` and forwards
943
+ // them through `onRecords` into the batcher. This is
944
+ // the round-6 equivalent of round 5's
945
+ // `observer.disconnect({ flush: true, notify: false })`:
946
+ // the batcher is now the destination, so we drain into
947
+ // the batcher first and let the regular disconnect path
948
+ // skip the flush. `notify: false` avoids scheduling a
949
+ // render during teardown.
950
+ // 2. `batcher.flush()` — applies the accumulated batch
951
+ // synchronously so the index / source registry are
952
+ // accurate through the next `disposeAll()`. Without
953
+ // this step, an external mutation that landed just
954
+ // before disconnect would survive only inside the
955
+ // batcher's pending batch and be discarded together
956
+ // with the batch on the next `flush()` (which won't
957
+ // run because the observer is no longer attached).
958
+ // 3. `observer.disconnect()` — stops the underlying
959
+ // `MutationObserver` from delivering future records.
960
+ // Defaults to drain-only (`flush: false`) because step 1
961
+ // already forwarded the queue; the observer's `finally`
962
+ // `takeRecords()` is defensive.
963
+ // 4. `index.disposeAll()` — clears operations while
964
+ // preserving the source registry for the next reconnect.
965
+ //
966
+ // No double-disconnect — step 3 is the only call to the
967
+ // underlying `observer.disconnect()`.
968
+ this.observer.flushPendingRecords({ notify: false });
969
+ this.batcher.flush();
970
+ this.observer.disconnect();
971
+ this.index.disposeAll();
972
+ // Shadow roots tear down in the same order: drain observer
973
+ // → flush batcher → disconnect observer → dispose index.
974
+ // `_disconnectShadowRoots()` clears `shadowBindings`, so a
975
+ // subsequent `hostConnected()` rebuilds them from scratch.
976
+ this._disconnectShadowRoots();
977
+ }
978
+ /**
979
+ * Explicit-disposal alias for `hostDisconnected()`.
980
+ *
981
+ * Lit `ReactiveController`s are torn down automatically when
982
+ * the host is disconnected, so most consumers never need this
983
+ * method. It exists for two cases:
984
+ *
985
+ * - Tests that want a deterministic, intent-revealing way to
986
+ * tear down the controller from the test body, without
987
+ * reaching into the Lit host's `hostDisconnected()` hook
988
+ * manually. This makes the teardown intent obvious in
989
+ * tests that exercise the observer / index wiring in
990
+ * isolation.
991
+ * - Application code that owns a controller instance and wants
992
+ * to mirror the Lit `hostConnected` / `hostDisconnected`
993
+ * pair with a single, intent-revealing `dispose()` call —
994
+ * for example, when a controller's lifetime is decoupled
995
+ * from the host element (a controller that should be
996
+ * disposed when a feature is unmounted, even if the host
997
+ * stays connected for other reasons).
998
+ *
999
+ * The controller still requires a Lit `ReactiveControllerHost`
1000
+ * because the constructor calls `host.addController(this)` —
1001
+ * `dispose()` does not enable non-Lit integrations on its own,
1002
+ * it only offers a more direct teardown path for the
1003
+ * already-Lit-managed controller. If you need a true non-Lit
1004
+ * integration, wrap the controller in a minimal
1005
+ * `ReactiveControllerHost` adapter.
1006
+ *
1007
+ * The behaviour is identical to `hostDisconnected()`: the
1008
+ * observer is drained into the batcher via
1009
+ * `flushPendingRecords({ notify: false })`, the batcher is
1010
+ * flushed synchronously, the observer is disconnected, and the
1011
+ * index runs `disposeAll()`. Safe to call multiple times — the
1012
+ * underlying `MutationObserver.disconnect()` is idempotent and
1013
+ * the observer is one-shot, so a second call is a no-op.
1014
+ */
1015
+ dispose() {
1016
+ this.hostDisconnected();
1017
+ }
1018
+ }
1019
+ /**
1020
+ * Convenience constructor used by `BdScopedElement` (in a follow-up
1021
+ * task) and by tests that want a one-line setup.
1022
+ *
1023
+ * The controller owns its own cache and factory — neither is a
1024
+ * public option. This constructor simply forwards to
1025
+ * `new EtaTemplateController(host, options)` with no
1026
+ * transformations. It exists as a named export so callers do not
1027
+ * have to import the class directly.
1028
+ *
1029
+ * `args` and `context` are required by `EtaTemplateController` and
1030
+ * must be supplied by the caller. The convenience constructor does
1031
+ * not synthesise defaults for them.
1032
+ */
1033
+ export function createEtaTemplateController(host, options) {
1034
+ return new EtaTemplateController(host, options);
1035
+ }
1036
+ //# sourceMappingURL=controller.js.map