snice 7.5.0 → 7.7.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 (265) hide show
  1. package/adapters/react/SniceProvider.js +1 -1
  2. package/adapters/react/SniceRouter.js +1 -1
  3. package/adapters/react/avatar.d.ts +1 -0
  4. package/adapters/react/avatar.d.ts.map +1 -1
  5. package/adapters/react/avatar.js +1 -1
  6. package/adapters/react/avatar.js.map +1 -1
  7. package/adapters/react/avatar.tsx +2 -1
  8. package/adapters/react/calendar.d.ts +4 -0
  9. package/adapters/react/calendar.d.ts.map +1 -1
  10. package/adapters/react/calendar.js +1 -1
  11. package/adapters/react/calendar.js.map +1 -1
  12. package/adapters/react/calendar.tsx +5 -1
  13. package/adapters/react/matchRoute.js +68 -2
  14. package/adapters/react/matchRoute.js.map +1 -1
  15. package/adapters/react/route-match.d.ts +7 -0
  16. package/adapters/react/useRequestHandler.js +1 -1
  17. package/bin/analyzer-contracts.js +60 -2
  18. package/bin/project-analyzer.js +1262 -118
  19. package/bin/snice.js +95 -5
  20. package/custom-elements.json +112 -0
  21. package/dist/cdn/accordion/snice-accordion.js +1 -1
  22. package/dist/cdn/action-bar/snice-action-bar.js +1 -1
  23. package/dist/cdn/activity-feed/snice-activity-feed.js +1 -1
  24. package/dist/cdn/alert/snice-alert.js +1 -1
  25. package/dist/cdn/app-tiles/snice-app-tiles.js +1 -1
  26. package/dist/cdn/approval-flow/snice-approval-flow.js +1 -1
  27. package/dist/cdn/audio-recorder/snice-audio-recorder.js +1 -1
  28. package/dist/cdn/availability/snice-availability.js +1 -1
  29. package/dist/cdn/avatar/README.md +1 -1
  30. package/dist/cdn/avatar/snice-avatar.js +9 -3
  31. package/dist/cdn/avatar/snice-avatar.js.map +1 -1
  32. package/dist/cdn/avatar/snice-avatar.min.js +2 -2
  33. package/dist/cdn/avatar/snice-avatar.min.js.map +1 -1
  34. package/dist/cdn/avatar-group/snice-avatar-group.js +1 -1
  35. package/dist/cdn/badge/snice-badge.js +1 -1
  36. package/dist/cdn/banner/snice-banner.js +1 -1
  37. package/dist/cdn/binpack/snice-binpack.js +1 -1
  38. package/dist/cdn/book/snice-book.js +1 -1
  39. package/dist/cdn/booking/snice-booking.js +1 -1
  40. package/dist/cdn/breadcrumbs/snice-breadcrumbs.js +1 -1
  41. package/dist/cdn/button/snice-button.js +2 -2
  42. package/dist/cdn/button/snice-button.js.map +1 -1
  43. package/dist/cdn/button/snice-button.min.js +1 -1
  44. package/dist/cdn/button/snice-button.min.js.map +1 -1
  45. package/dist/cdn/calendar/README.md +2 -2
  46. package/dist/cdn/calendar/snice-calendar.js +562 -40
  47. package/dist/cdn/calendar/snice-calendar.js.map +1 -1
  48. package/dist/cdn/calendar/snice-calendar.min.js +28 -1
  49. package/dist/cdn/calendar/snice-calendar.min.js.map +1 -1
  50. package/dist/cdn/camera/snice-camera.js +1 -1
  51. package/dist/cdn/camera-annotate/snice-camera-annotate.js +1 -1
  52. package/dist/cdn/candlestick/snice-candlestick.js +1 -1
  53. package/dist/cdn/card/snice-card.js +1 -1
  54. package/dist/cdn/carousel/snice-carousel.js +1 -1
  55. package/dist/cdn/cart/snice-cart.js +1 -1
  56. package/dist/cdn/chart/snice-chart.js +1 -1
  57. package/dist/cdn/chat/snice-chat.js +1 -1
  58. package/dist/cdn/checkbox/snice-checkbox.js +1 -1
  59. package/dist/cdn/chip/snice-chip.js +1 -1
  60. package/dist/cdn/code-block/snice-code-block.js +1 -1
  61. package/dist/cdn/color-display/snice-color-display.js +1 -1
  62. package/dist/cdn/color-picker/snice-color-picker.js +1 -1
  63. package/dist/cdn/command-palette/snice-command-palette.js +1 -1
  64. package/dist/cdn/comments/snice-comments.js +1 -1
  65. package/dist/cdn/countdown/snice-countdown.js +1 -1
  66. package/dist/cdn/cropper/snice-cropper.js +1 -1
  67. package/dist/cdn/data-card/snice-data-card.js +1 -1
  68. package/dist/cdn/date-picker/snice-date-picker.js +1 -1
  69. package/dist/cdn/date-range-picker/snice-date-range-picker.js +1 -1
  70. package/dist/cdn/date-time-picker/snice-date-time-picker.js +1 -1
  71. package/dist/cdn/diff/snice-diff.js +1 -1
  72. package/dist/cdn/divider/snice-divider.js +1 -1
  73. package/dist/cdn/doc/snice-doc.js +1 -1
  74. package/dist/cdn/draw/snice-draw.js +1 -1
  75. package/dist/cdn/drawer/snice-drawer.js +1 -1
  76. package/dist/cdn/empty-state/snice-empty-state.js +1 -1
  77. package/dist/cdn/estimate/snice-estimate.js +1 -1
  78. package/dist/cdn/file-gallery/snice-file-gallery.js +1 -1
  79. package/dist/cdn/file-upload/snice-file-upload.js +1 -1
  80. package/dist/cdn/flip-card/snice-flip-card.js +1 -1
  81. package/dist/cdn/flow/snice-flow.js +1 -1
  82. package/dist/cdn/form-layout/snice-form-layout.js +1 -1
  83. package/dist/cdn/funnel/snice-funnel.js +1 -1
  84. package/dist/cdn/gantt/snice-gantt.js +1 -1
  85. package/dist/cdn/gauge/snice-gauge.js +1 -1
  86. package/dist/cdn/grid/snice-grid.js +1 -1
  87. package/dist/cdn/heatmap/snice-heatmap.js +1 -1
  88. package/dist/cdn/image/snice-image.js +1 -1
  89. package/dist/cdn/input/snice-input.js +1 -1
  90. package/dist/cdn/invoice/snice-invoice.js +1 -1
  91. package/dist/cdn/kanban/snice-kanban.js +1 -1
  92. package/dist/cdn/key-value/snice-key-value.js +1 -1
  93. package/dist/cdn/kpi/snice-kpi.js +1 -1
  94. package/dist/cdn/layout/snice-layout.js +1 -1
  95. package/dist/cdn/leaderboard/snice-leaderboard.js +1 -1
  96. package/dist/cdn/link/snice-link.js +1 -1
  97. package/dist/cdn/link-preview/snice-link-preview.js +1 -1
  98. package/dist/cdn/list/snice-list.js +1 -1
  99. package/dist/cdn/location/snice-location.js +1 -1
  100. package/dist/cdn/login/README.md +1 -1
  101. package/dist/cdn/login/snice-login.js +2 -2
  102. package/dist/cdn/login/snice-login.js.map +1 -1
  103. package/dist/cdn/login/snice-login.min.js +1 -1
  104. package/dist/cdn/login/snice-login.min.js.map +1 -1
  105. package/dist/cdn/map/snice-map.js +1 -1
  106. package/dist/cdn/markdown/snice-markdown.js +1 -1
  107. package/dist/cdn/masonry/snice-masonry.js +1 -1
  108. package/dist/cdn/menu/snice-menu.js +1 -1
  109. package/dist/cdn/message-strip/snice-message-strip.js +1 -1
  110. package/dist/cdn/modal/snice-modal.js +1 -1
  111. package/dist/cdn/music-player/snice-music-player.js +1 -1
  112. package/dist/cdn/nav/snice-nav.js +1 -1
  113. package/dist/cdn/network-graph/snice-network-graph.js +1 -1
  114. package/dist/cdn/notification-center/snice-notification-center.js +1 -1
  115. package/dist/cdn/order-tracker/snice-order-tracker.js +1 -1
  116. package/dist/cdn/org-chart/snice-org-chart.js +1 -1
  117. package/dist/cdn/pagination/snice-pagination.js +1 -1
  118. package/dist/cdn/paint/snice-paint.js +1 -1
  119. package/dist/cdn/pdf-viewer/snice-pdf-viewer.js +1 -1
  120. package/dist/cdn/permission-matrix/snice-permission-matrix.js +1 -1
  121. package/dist/cdn/podcast-player/snice-podcast-player.js +1 -1
  122. package/dist/cdn/popover/snice-popover.js +1 -1
  123. package/dist/cdn/pricing-table/snice-pricing-table.js +1 -1
  124. package/dist/cdn/product-card/snice-product-card.js +1 -1
  125. package/dist/cdn/progress/snice-progress.js +1 -1
  126. package/dist/cdn/progress-ring/snice-progress-ring.js +1 -1
  127. package/dist/cdn/qr-code/snice-qr-code.js +1 -1
  128. package/dist/cdn/qr-reader/snice-qr-reader.js +1 -1
  129. package/dist/cdn/radio/snice-radio.js +1 -1
  130. package/dist/cdn/range-slider/snice-range-slider.js +1 -1
  131. package/dist/cdn/rating/snice-rating.js +1 -1
  132. package/dist/cdn/receipt/snice-receipt.js +1 -1
  133. package/dist/cdn/recipe/snice-recipe.js +1 -1
  134. package/dist/cdn/runtime/README.md +2 -2
  135. package/dist/cdn/runtime/snice-runtime.esm.js +706 -267
  136. package/dist/cdn/runtime/snice-runtime.esm.js.map +1 -1
  137. package/dist/cdn/runtime/snice-runtime.esm.min.js +1 -1
  138. package/dist/cdn/runtime/snice-runtime.esm.min.js.map +1 -1
  139. package/dist/cdn/runtime/snice-runtime.js +706 -267
  140. package/dist/cdn/runtime/snice-runtime.js.map +1 -1
  141. package/dist/cdn/runtime/snice-runtime.min.js +1 -1
  142. package/dist/cdn/runtime/snice-runtime.min.js.map +1 -1
  143. package/dist/cdn/sankey/snice-sankey.js +1 -1
  144. package/dist/cdn/segmented-control/snice-segmented-control.js +1 -1
  145. package/dist/cdn/select/snice-select.js +3 -1
  146. package/dist/cdn/select/snice-select.js.map +1 -1
  147. package/dist/cdn/select/snice-select.min.js +2 -2
  148. package/dist/cdn/select/snice-select.min.js.map +1 -1
  149. package/dist/cdn/skeleton/snice-skeleton.js +1 -1
  150. package/dist/cdn/slider/snice-slider.js +1 -1
  151. package/dist/cdn/sortable/snice-sortable.js +1 -1
  152. package/dist/cdn/sparkline/snice-sparkline.js +1 -1
  153. package/dist/cdn/spinner/snice-spinner.js +1 -1
  154. package/dist/cdn/split-button/snice-split-button.js +1 -1
  155. package/dist/cdn/split-pane/snice-split-pane.js +1 -1
  156. package/dist/cdn/spotlight/snice-spotlight.js +1 -1
  157. package/dist/cdn/stat-group/snice-stat-group.js +1 -1
  158. package/dist/cdn/step-input/snice-step-input.js +1 -1
  159. package/dist/cdn/stepper/snice-stepper.js +1 -1
  160. package/dist/cdn/switch/README.md +1 -1
  161. package/dist/cdn/switch/snice-switch.js +8 -4
  162. package/dist/cdn/switch/snice-switch.js.map +1 -1
  163. package/dist/cdn/switch/snice-switch.min.js +8 -4
  164. package/dist/cdn/switch/snice-switch.min.js.map +1 -1
  165. package/dist/cdn/table/README.md +2 -2
  166. package/dist/cdn/table/snice-table.esm.js +77 -5
  167. package/dist/cdn/table/snice-table.esm.js.map +1 -1
  168. package/dist/cdn/table/snice-table.esm.min.js +68 -47
  169. package/dist/cdn/table/snice-table.esm.min.js.map +1 -1
  170. package/dist/cdn/table/snice-table.js +76 -4
  171. package/dist/cdn/table/snice-table.js.map +1 -1
  172. package/dist/cdn/table/snice-table.min.js +53 -32
  173. package/dist/cdn/table/snice-table.min.js.map +1 -1
  174. package/dist/cdn/tabs/snice-tabs.js +1 -1
  175. package/dist/cdn/tag/snice-tag.js +1 -1
  176. package/dist/cdn/tag-input/snice-tag-input.js +1 -1
  177. package/dist/cdn/terminal/snice-terminal.js +1 -1
  178. package/dist/cdn/testimonial/snice-testimonial.js +1 -1
  179. package/dist/cdn/textarea/snice-textarea.js +1 -1
  180. package/dist/cdn/time-picker/snice-time-picker.js +1 -1
  181. package/dist/cdn/time-range-picker/snice-time-range-picker.js +1 -1
  182. package/dist/cdn/timeline/snice-timeline.js +1 -1
  183. package/dist/cdn/timer/snice-timer.js +1 -1
  184. package/dist/cdn/toast/snice-toast.js +1 -1
  185. package/dist/cdn/tooltip/snice-tooltip.js +1 -1
  186. package/dist/cdn/tree/snice-tree.js +1 -1
  187. package/dist/cdn/treemap/snice-treemap.js +1 -1
  188. package/dist/cdn/user-card/snice-user-card.js +1 -1
  189. package/dist/cdn/video-player/snice-video-player.js +1 -1
  190. package/dist/cdn/virtual-scroller/snice-virtual-scroller.js +1 -1
  191. package/dist/cdn/waterfall/snice-waterfall.js +1 -1
  192. package/dist/cdn/weather/snice-weather.js +1 -1
  193. package/dist/cdn/work-order/snice-work-order.js +1 -1
  194. package/dist/components/avatar/snice-avatar.d.ts +1 -0
  195. package/dist/components/avatar/snice-avatar.js +8 -2
  196. package/dist/components/avatar/snice-avatar.js.map +1 -1
  197. package/dist/components/avatar/snice-avatar.types.d.ts +2 -0
  198. package/dist/components/button/snice-button.js +1 -1
  199. package/dist/components/button/snice-button.js.map +1 -1
  200. package/dist/components/calendar/snice-calendar.d.ts +38 -1
  201. package/dist/components/calendar/snice-calendar.js +390 -40
  202. package/dist/components/calendar/snice-calendar.js.map +1 -1
  203. package/dist/components/calendar/snice-calendar.types.d.ts +63 -0
  204. package/dist/components/select/snice-select.js +2 -0
  205. package/dist/components/select/snice-select.js.map +1 -1
  206. package/dist/components/switch/snice-switch.js +7 -3
  207. package/dist/components/switch/snice-switch.js.map +1 -1
  208. package/dist/components/table/snice-table.d.ts +9 -0
  209. package/dist/components/table/snice-table.js +72 -2
  210. package/dist/components/table/snice-table.js.map +1 -1
  211. package/dist/components/table/table-keyboard.js +1 -1
  212. package/dist/components/table/table-keyboard.js.map +1 -1
  213. package/dist/index.cjs +703 -264
  214. package/dist/index.cjs.map +1 -1
  215. package/dist/index.d.ts +3 -1
  216. package/dist/index.esm.js +703 -264
  217. package/dist/index.esm.js.map +1 -1
  218. package/dist/index.iife.js +703 -264
  219. package/dist/index.iife.js.map +1 -1
  220. package/dist/parts.d.ts +14 -4
  221. package/dist/react/SniceProvider.js +1 -1
  222. package/dist/react/SniceRouter.js +1 -1
  223. package/dist/react/index.js +1 -1
  224. package/dist/react/matchRoute.js +68 -2
  225. package/dist/react/matchRoute.js.map +1 -1
  226. package/dist/react/route-match.d.ts +7 -0
  227. package/dist/react/useRequestHandler.js +1 -1
  228. package/dist/render-errors.d.ts +9 -0
  229. package/dist/route-match.d.ts +7 -0
  230. package/dist/symbols.cjs +1 -1
  231. package/dist/symbols.esm.js +1 -1
  232. package/dist/template-marker.d.ts +5 -0
  233. package/dist/testing-dom.cjs +1 -1
  234. package/dist/testing-dom.esm.js +1 -1
  235. package/dist/testing.d.ts +3 -0
  236. package/dist/transitions.cjs +1 -1
  237. package/dist/transitions.esm.js +1 -1
  238. package/dist/types/dispatch-options.d.ts +5 -4
  239. package/dist/types/event-timing.d.ts +7 -0
  240. package/dist/types/index.d.ts +1 -0
  241. package/dist/types/on-options.d.ts +21 -6
  242. package/dist/utils.d.ts +3 -0
  243. package/docs/ai/DEVELOPMENT.md +1 -1
  244. package/docs/ai/api.md +7 -2
  245. package/docs/ai/cli.md +14 -0
  246. package/docs/ai/components/avatar.md +1 -0
  247. package/docs/ai/components/calendar.md +24 -1
  248. package/docs/ai/components/switch.md +3 -0
  249. package/docs/ai/components/table.md +1 -1
  250. package/docs/ai/decorators.md +3 -2
  251. package/docs/ai/events.md +45 -8
  252. package/docs/ai/rendering.md +7 -0
  253. package/docs/ai/routing.md +6 -1
  254. package/docs/ai/testing.md +17 -0
  255. package/docs/cli.md +19 -0
  256. package/docs/components/avatar.md +1 -0
  257. package/docs/components/calendar.md +112 -2
  258. package/docs/components/switch.md +7 -1
  259. package/docs/components/table.md +2 -1
  260. package/docs/events.md +90 -10
  261. package/docs/rendering.md +10 -0
  262. package/docs/routing.md +22 -1
  263. package/docs/testing.md +19 -0
  264. package/package.json +2 -2
  265. package/vscode.html-custom-data.json +28 -0
package/dist/index.cjs CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * snice v7.4.0
2
+ * snice v7.6.0
3
3
  * A decorator-driven web component library with routing, controllers, daemons, and 130+ UI components. For better coding-agent results, run npx snice init-ai.
4
4
  * (c) 2024
5
5
  * Released under the MIT License.
@@ -716,6 +716,18 @@ function createThrottled(fn, delay) {
716
716
  }
717
717
  };
718
718
  }
719
+ /** Resolve and validate an @on/@dispatch debounce or throttle interval. */
720
+ function resolveEventTiming(instance, timing, decorator, option) {
721
+ if (timing === undefined)
722
+ return undefined;
723
+ const value = typeof timing === 'function'
724
+ ? timing.call(instance)
725
+ : timing;
726
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
727
+ throw new TypeError(`${decorator} ${option} must resolve to a finite, non-negative number of milliseconds.`);
728
+ }
729
+ return value;
730
+ }
719
731
  /**
720
732
  * Resolve a scope option to a concrete EventTarget.
721
733
  *
@@ -1849,6 +1861,11 @@ function on(eventName, selectorOrOptions, options) {
1849
1861
  else if (selectorOrOptions === null && options) {
1850
1862
  opts = options;
1851
1863
  }
1864
+ // `target` is the options-object spelling of the positional selector; fold it
1865
+ // into the same delegation path. The positional argument wins when both are given.
1866
+ if (!selector && opts.target) {
1867
+ selector = opts.target;
1868
+ }
1852
1869
  return function (originalMethod, context) {
1853
1870
  const methodName = context.name;
1854
1871
  context.addInitializer(function () {
@@ -1860,7 +1877,7 @@ function on(eventName, selectorOrOptions, options) {
1860
1877
  // and child registrations pollute parent (and vice versa) via the
1861
1878
  // prototype chain.
1862
1879
  const eventNames = Array.isArray(eventName) ? eventName : [eventName];
1863
- const registrationKey = `${methodName}::${eventNames.join(',')}::${selector ?? ''}::daemon:${opts.daemon ?? ''}`;
1880
+ const registrationKey = `${methodName}::${eventNames.join(',')}::${selector ?? ''}::daemon:${opts.daemon ?? ''}::light:${opts.light ?? ''}::shadow:${opts.shadow ?? ''}`;
1864
1881
  if (!Object.prototype.hasOwnProperty.call(constructor, ON_METHODS)) {
1865
1882
  constructor[ON_METHODS] = new Set();
1866
1883
  }
@@ -1940,6 +1957,10 @@ function setupEventHandlers(instance, targetElement) {
1940
1957
  const currentMethod = instance[handler.methodName];
1941
1958
  let boundMethod = currentMethod ? currentMethod.bind(instance) : handler.method.bind(instance);
1942
1959
  const handlerOptions = handler.options || {};
1960
+ const debounceDelay = resolveEventTiming(instance, handlerOptions.debounce, '@on', 'debounce');
1961
+ const throttleDelay = debounceDelay && debounceDelay > 0
1962
+ ? undefined
1963
+ : resolveEventTiming(instance, handlerOptions.throttle, '@on', 'throttle');
1943
1964
  // Parse event name for key modifiers
1944
1965
  // Supports both dot notation (@keydown.enter) and colon notation (@keydown:Enter)
1945
1966
  // Only parse colons for keyboard events, not custom events
@@ -1960,13 +1981,13 @@ function setupEventHandlers(instance, targetElement) {
1960
1981
  if (delimiterIndex <= 0)
1961
1982
  warnIfModifierMisuse(handler.eventName);
1962
1983
  // Apply debounce (takes precedence over throttle)
1963
- if (handlerOptions.debounce && handlerOptions.debounce > 0) {
1964
- const debounced = createDebounced(boundMethod, handlerOptions.debounce);
1984
+ if (debounceDelay && debounceDelay > 0) {
1985
+ const debounced = createDebounced(boundMethod, debounceDelay);
1965
1986
  instance[CLEANUP].eventCancels.push(() => debounced.cancel());
1966
1987
  boundMethod = debounced;
1967
1988
  }
1968
- else if (handlerOptions.throttle && handlerOptions.throttle > 0) {
1969
- boundMethod = throttle$1(boundMethod, handlerOptions.throttle);
1989
+ else if (throttleDelay && throttleDelay > 0) {
1990
+ boundMethod = throttle$1(boundMethod, throttleDelay);
1970
1991
  }
1971
1992
  // Create event handler with key modifier support
1972
1993
  // Uses shared keyboard filter implementation from parts.ts
@@ -1974,15 +1995,21 @@ function setupEventHandlers(instance, targetElement) {
1974
1995
  if (keyModifier && ['keydown', 'keyup', 'keypress'].includes(baseEventName)) {
1975
1996
  keyFilter = parseKeyboardFilter(keyModifier);
1976
1997
  }
1998
+ // Returns whether the method actually ran, so `once` can be managed
1999
+ // manually: native `once` consumes the listener on ANY invocation — a
2000
+ // non-matching key or (with multiple tree listeners) a different root
2001
+ // would either eat the single firing or allow a second one.
1977
2002
  const createKeyModifierHandler = (method) => {
1978
2003
  if (!keyFilter) {
1979
- return method;
2004
+ return (event) => { method(event); return true; };
1980
2005
  }
1981
2006
  return (event) => {
1982
2007
  const keyEvent = event;
1983
2008
  if (matchesKeyboardFilter(keyEvent, keyFilter)) {
1984
2009
  method(event);
2010
+ return true;
1985
2011
  }
2012
+ return false;
1986
2013
  };
1987
2014
  };
1988
2015
  // Apply key modifier wrapper
@@ -2000,6 +2027,20 @@ function setupEventHandlers(instance, targetElement) {
2000
2027
  continue;
2001
2028
  }
2002
2029
  const hasExplicitTarget = hasExplicitScope || hasDaemon;
2030
+ // Tree toggles — the same light/shadow pair @query uses. Both default to
2031
+ // true: direct listeners attach in both trees, delegated listeners match
2032
+ // in both. An explicit scope/daemon owns the listener target outright, so
2033
+ // the flags are meaningless there and ignored with a warning.
2034
+ const inShadow = handlerOptions.shadow !== false;
2035
+ const inLight = handlerOptions.light !== false;
2036
+ if (hasExplicitTarget
2037
+ && (handlerOptions.light !== undefined || handlerOptions.shadow !== undefined)) {
2038
+ console.warn(`[snice/@on] light/shadow are ignored for "${handler.eventName}" — an explicit scope/daemon owns the listener target.`);
2039
+ }
2040
+ if (!hasExplicitTarget && !inShadow && !inLight) {
2041
+ console.warn(`[snice/@on] "${handler.eventName}" disables both light and shadow — listener skipped.`);
2042
+ continue;
2043
+ }
2003
2044
  let scopedTarget = null;
2004
2045
  if (hasDaemon) {
2005
2046
  try {
@@ -2021,56 +2062,125 @@ function setupEventHandlers(instance, targetElement) {
2021
2062
  }
2022
2063
  // Main event handler with error handling and event delegation
2023
2064
  if (handler.selector) {
2024
- // Delegated event handling.
2025
- // - Default eventRoot: shadow root if present, else host element.
2026
- // - With explicit scope: attach to the scoped target instead.
2027
- const eventRoot = hasExplicitTarget
2028
- ? scopedTarget
2029
- : (targetElement.shadowRoot || targetElement);
2030
- const delegatedHandler = (event) => {
2031
- const target = event.target;
2032
- const matchingElement = (target.matches && target.matches(handler.selector) && target) ||
2033
- (target.closest && target.closest(handler.selector)) ||
2034
- null;
2035
- if (!matchingElement)
2036
- return;
2037
- if (handlerOptions.preventDefault)
2038
- event.preventDefault();
2039
- if (handlerOptions.stopPropagation) {
2040
- event.stopPropagation();
2041
- event.stopImmediatePropagation();
2042
- }
2043
- try {
2044
- keyModifierMethod(event);
2045
- }
2046
- catch (error) {
2047
- console.error(`Error in event handler ${handler.methodName}:`, error);
2048
- }
2049
- };
2065
+ // Delegated event handling. Listener roots by tree toggle:
2066
+ // - shadow → the shadow root (when present)
2067
+ // - light → the host element (hears light-DOM bubbles)
2068
+ // - explicit scope: the scoped target only
2069
+ const componentShadowRoot = targetElement.shadowRoot;
2070
+ const delegationRoots = hasExplicitTarget
2071
+ ? [scopedTarget]
2072
+ : [
2073
+ ...(inShadow && componentShadowRoot ? [componentShadowRoot] : []),
2074
+ ...(inLight ? [targetElement] : []),
2075
+ ];
2076
+ if (delegationRoots.length === 0) {
2077
+ console.warn(`[snice/@on] "${handler.eventName}" cannot delegate "${handler.selector}" — no shadow root and light disabled; listener skipped.`);
2078
+ continue;
2079
+ }
2080
+ // One event can reach more than one root (e.g. a slotted click seen by
2081
+ // both the shadow root and the host); a per-handler Symbol keeps the
2082
+ // handler to one invocation per event. The symbol is only stamped on a
2083
+ // match so a non-matching root never blocks the other.
2084
+ const handledSymbol = Symbol();
2050
2085
  // Auto-enable capture for non-bubbling events when using delegation
2051
2086
  const needsCapture = NON_BUBBLING_EVENTS.has(baseEventName);
2052
2087
  const useCapture = handlerOptions.capture !== undefined
2053
2088
  ? handlerOptions.capture
2054
2089
  : needsCapture;
2090
+ // `once` is managed manually (never native): the handler fires exactly
2091
+ // once — a non-matching event or key must not consume it, and a match
2092
+ // must retire the listeners on EVERY root, not just the one that fired.
2055
2093
  const listenerOptions = {
2056
2094
  capture: useCapture,
2057
- once: handlerOptions.once || false,
2095
+ once: false,
2058
2096
  passive: handlerOptions.passive || false,
2059
2097
  };
2060
- eventRoot.addEventListener(baseEventName, delegatedHandler, listenerOptions);
2061
- instance[CLEANUP].events.push({
2062
- target: eventRoot,
2063
- eventName: baseEventName,
2064
- handler: delegatedHandler,
2065
- options: listenerOptions,
2066
- });
2098
+ const listenerGroup = [];
2099
+ const removeGroup = () => {
2100
+ for (const entry of listenerGroup) {
2101
+ entry.target.removeEventListener(baseEventName, entry.handler, listenerOptions);
2102
+ }
2103
+ };
2104
+ for (const eventRoot of delegationRoots) {
2105
+ // Delegation matches only elements visible in the listener's own tree
2106
+ // scope: a shadow-root listener matches its shadow tree, anything else
2107
+ // matches its containing document/root. This mirrors native retargeting
2108
+ // (child-component internals never match) while still matching a shadow
2109
+ // wrapper when the click lands on light-DOM content slotted into it —
2110
+ // `target.closest()` alone cannot cross that slot boundary.
2111
+ const treeScope = eventRoot instanceof ShadowRoot
2112
+ ? eventRoot
2113
+ : (eventRoot.getRootNode?.() ?? document);
2114
+ const findDelegateMatch = (event) => {
2115
+ if (typeof event.composedPath !== 'function') {
2116
+ // Fallback for environments without composedPath: ancestor walk.
2117
+ const target = event.target;
2118
+ return (target.matches && target.matches(handler.selector) && target)
2119
+ || (target.closest && target.closest(handler.selector))
2120
+ || null;
2121
+ }
2122
+ for (const node of event.composedPath()) {
2123
+ const el = node;
2124
+ if (el === eventRoot)
2125
+ break;
2126
+ if (el.nodeType !== 1)
2127
+ continue;
2128
+ if (el.getRootNode() !== treeScope)
2129
+ continue;
2130
+ if (el.matches(handler.selector))
2131
+ return el;
2132
+ }
2133
+ return null;
2134
+ };
2135
+ const delegatedHandler = (event) => {
2136
+ if (event[handledSymbol])
2137
+ return;
2138
+ const matchingElement = findDelegateMatch(event);
2139
+ if (!matchingElement)
2140
+ return;
2141
+ event[handledSymbol] = true;
2142
+ if (handlerOptions.preventDefault)
2143
+ event.preventDefault();
2144
+ if (handlerOptions.stopPropagation) {
2145
+ event.stopPropagation();
2146
+ event.stopImmediatePropagation();
2147
+ }
2148
+ let fired = false;
2149
+ try {
2150
+ fired = keyModifierMethod(event);
2151
+ }
2152
+ catch (error) {
2153
+ // A throw can only escape user code, so the handler DID run.
2154
+ fired = true;
2155
+ console.error(`Error in event handler ${handler.methodName}:`, error);
2156
+ }
2157
+ if (fired && handlerOptions.once)
2158
+ removeGroup();
2159
+ };
2160
+ eventRoot.addEventListener(baseEventName, delegatedHandler, listenerOptions);
2161
+ listenerGroup.push({ target: eventRoot, handler: delegatedHandler });
2162
+ instance[CLEANUP].events.push({
2163
+ target: eventRoot,
2164
+ eventName: baseEventName,
2165
+ handler: delegatedHandler,
2166
+ options: listenerOptions,
2167
+ });
2168
+ }
2067
2169
  }
2068
2170
  else {
2069
2171
  // Direct event handling.
2070
2172
  // - Default: shadow root + host element (so events inside shadow and on
2071
2173
  // the host itself both fire). A per-handler Symbol dedupes.
2174
+ // - light/shadow narrow the attachment: shadow → the shadow root
2175
+ // listener, light → the host listener.
2072
2176
  // - With explicit scope: attach to the scoped target ONLY (no duplication).
2073
- const shadowRoot = hasExplicitTarget ? null : targetElement.shadowRoot;
2177
+ const shadowRoot = (hasExplicitTarget || !inShadow)
2178
+ ? null
2179
+ : targetElement.shadowRoot;
2180
+ if (!hasExplicitTarget && !shadowRoot && !inLight) {
2181
+ console.warn(`[snice/@on] "${handler.eventName}" requests shadow only but the element has no shadow root — listener skipped.`);
2182
+ continue;
2183
+ }
2074
2184
  // Per-handler private Symbol so dedup is scoped to THIS handler's two
2075
2185
  // listeners (shadowRoot + host). Using Symbol.for() with the method name
2076
2186
  // would collide across components that share a method name (e.g. a parent
@@ -2078,6 +2188,20 @@ function setupEventHandlers(instance, targetElement) {
2078
2188
  // handler to be silently swallowed when events bubble up through a
2079
2189
  // shadow boundary.
2080
2190
  const handledSymbol = Symbol();
2191
+ // `once` is managed manually (never native): a key-filtered handler must
2192
+ // not be consumed by a non-matching key, and with listeners in two trees
2193
+ // the first real firing must retire BOTH, not just the one that fired.
2194
+ const listenerOptions = {
2195
+ capture: handlerOptions.capture || false,
2196
+ once: false,
2197
+ passive: handlerOptions.passive || false,
2198
+ };
2199
+ const listenerGroup = [];
2200
+ const removeGroup = () => {
2201
+ for (const entry of listenerGroup) {
2202
+ entry.target.removeEventListener(baseEventName, entry.handler, listenerOptions);
2203
+ }
2204
+ };
2081
2205
  const wrappedMethod = (event) => {
2082
2206
  if (event[handledSymbol])
2083
2207
  return;
@@ -2086,46 +2210,39 @@ function setupEventHandlers(instance, targetElement) {
2086
2210
  event.preventDefault();
2087
2211
  if (handlerOptions.stopPropagation)
2088
2212
  event.stopPropagation();
2213
+ let fired = false;
2089
2214
  try {
2090
- keyModifierMethod(event);
2215
+ fired = keyModifierMethod(event);
2091
2216
  }
2092
2217
  catch (error) {
2218
+ // A throw can only escape user code, so the handler DID run.
2219
+ fired = true;
2093
2220
  console.error(`Error in event handler ${handler.methodName}:`, error);
2094
2221
  }
2222
+ if (fired && handlerOptions.once)
2223
+ removeGroup();
2095
2224
  };
2096
- const listenerOptions = {
2097
- capture: handlerOptions.capture || false,
2098
- once: handlerOptions.once || false,
2099
- passive: handlerOptions.passive || false,
2100
- };
2101
- if (hasExplicitTarget) {
2102
- scopedTarget.addEventListener(baseEventName, wrappedMethod, listenerOptions);
2225
+ const attach = (target) => {
2226
+ target.addEventListener(baseEventName, wrappedMethod, listenerOptions);
2227
+ listenerGroup.push({ target, handler: wrappedMethod });
2103
2228
  instance[CLEANUP].events.push({
2104
- target: scopedTarget,
2229
+ target,
2105
2230
  eventName: baseEventName,
2106
2231
  handler: wrappedMethod,
2107
2232
  options: listenerOptions,
2108
2233
  });
2234
+ };
2235
+ if (hasExplicitTarget) {
2236
+ attach(scopedTarget);
2109
2237
  continue;
2110
2238
  }
2111
- if (shadowRoot) {
2112
- // Listen on shadow root for events inside shadow DOM
2113
- shadowRoot.addEventListener(baseEventName, wrappedMethod, listenerOptions);
2114
- instance[CLEANUP].events.push({
2115
- target: shadowRoot,
2116
- eventName: baseEventName,
2117
- handler: wrappedMethod,
2118
- options: listenerOptions,
2119
- });
2120
- }
2121
- // Also listen on host element (for clicks on host itself or when no shadow root)
2122
- targetElement.addEventListener(baseEventName, wrappedMethod, listenerOptions);
2123
- instance[CLEANUP].events.push({
2124
- target: targetElement,
2125
- eventName: baseEventName,
2126
- handler: wrappedMethod,
2127
- options: listenerOptions,
2128
- });
2239
+ // Listen on shadow root for events inside shadow DOM
2240
+ if (shadowRoot)
2241
+ attach(shadowRoot);
2242
+ // Also listen on host element (for clicks on host itself, light-DOM
2243
+ // bubbles, or when no shadow root) — unless light is disabled.
2244
+ if (inLight)
2245
+ attach(targetElement);
2129
2246
  }
2130
2247
  }
2131
2248
  }
@@ -2145,6 +2262,185 @@ function cleanupEventHandlers(instance) {
2145
2262
  instance[CLEANUP].events = [];
2146
2263
  }
2147
2264
 
2265
+ // @dispatch decorator - auto-dispatches custom events from method return values
2266
+ const DISPATCH_GENERATION = getSymbol('dispatch-generation');
2267
+ function isDispatchGenerationActive(instance, generation) {
2268
+ return (instance[DISPATCH_GENERATION] ?? 0) === generation;
2269
+ }
2270
+ /**
2271
+ * Decorator that automatically dispatches a custom event after a method is called.
2272
+ * The return value of the method becomes the event detail.
2273
+ *
2274
+ * @param eventName The name of the event to dispatch
2275
+ * @param options Optional configuration extending EventInit
2276
+ */
2277
+ function dispatch(eventName, options) {
2278
+ return function (originalMethod, _context) {
2279
+ // Decorator-instance identity avoids collisions such as event `a_b` on
2280
+ // method `c` versus event `a` on method `b_c`.
2281
+ const timerKey = Symbol(`@dispatch:${eventName}`);
2282
+ return function (...args) {
2283
+ // Resolve against the actual decorated instance for every invocation.
2284
+ // Capture the generation before calling an async method so teardown can
2285
+ // invalidate work that has not reached the scheduling step yet.
2286
+ const generation = this[DISPATCH_GENERATION] ?? 0;
2287
+ // Create timing wrappers for dispatch (per-instance)
2288
+ if (!this[DISPATCH_TIMERS]) {
2289
+ this[DISPATCH_TIMERS] = new Map();
2290
+ }
2291
+ if (!this[DISPATCH_TIMERS].has(timerKey)) {
2292
+ this[DISPATCH_TIMERS].set(timerKey, {
2293
+ debounceTimeout: null,
2294
+ throttleLastCall: 0,
2295
+ throttleTimeout: null,
2296
+ invocation: 0,
2297
+ });
2298
+ }
2299
+ const timers = this[DISPATCH_TIMERS].get(timerKey);
2300
+ const debounceDelay = resolveEventTiming(this, options?.debounce, '@dispatch', 'debounce');
2301
+ const throttleDelay = debounceDelay && debounceDelay > 0
2302
+ ? undefined
2303
+ : resolveEventTiming(this, options?.throttle, '@dispatch', 'throttle');
2304
+ const invocation = ++timers.invocation;
2305
+ // A new invocation supersedes deferred work for this decorated method.
2306
+ // In particular, a resolver changing debounce to 0 must not leave the
2307
+ // previous positive-delay timer armed. Throttle trailing work is also
2308
+ // rebuilt below using this invocation's freshly resolved interval.
2309
+ if (timers.debounceTimeout)
2310
+ clearTimeout(timers.debounceTimeout);
2311
+ timers.debounceTimeout = null;
2312
+ if (timers.throttleTimeout)
2313
+ clearTimeout(timers.throttleTimeout);
2314
+ timers.throttleTimeout = null;
2315
+ timers.latestDetail = undefined;
2316
+ if (debounceDelay && debounceDelay > 0) {
2317
+ // Switching from throttle to debounce starts a new timing regime.
2318
+ timers.throttleLastCall = 0;
2319
+ }
2320
+ else if (!throttleDelay || throttleDelay <= 0) {
2321
+ // An un-timed invocation cancels both old regimes completely.
2322
+ timers.throttleLastCall = 0;
2323
+ }
2324
+ // Call the original method with preserved this context
2325
+ const result = originalMethod.apply(this, args);
2326
+ // Helper to dispatch the event
2327
+ const doDispatch = (detail) => {
2328
+ // Skip dispatch if result is undefined and dispatchOnUndefined is false
2329
+ if (detail === undefined && options?.dispatchOnUndefined === false) {
2330
+ return;
2331
+ }
2332
+ // Copy only platform EventInit fields; Snice-only decorator options do
2333
+ // not belong in the CustomEvent constructor dictionary.
2334
+ const event = new CustomEvent(eventName, {
2335
+ bubbles: options?.bubbles ?? true,
2336
+ cancelable: options?.cancelable ?? false,
2337
+ composed: options?.composed ?? true,
2338
+ detail
2339
+ });
2340
+ if (options?.scope !== undefined && options?.daemon !== undefined) {
2341
+ throw new TypeError(`@dispatch('${eventName}') cannot use both scope and daemon.`);
2342
+ }
2343
+ if (options?.daemon !== undefined) {
2344
+ requireDaemonTarget(this, options.daemon).dispatchEvent(event);
2345
+ return;
2346
+ }
2347
+ if (options?.scope !== undefined) {
2348
+ const target = resolveScope(this, options.scope);
2349
+ if (!target) {
2350
+ console.warn(`[snice/@dispatch] scope did not resolve for "${eventName}" — event not dispatched.`);
2351
+ return;
2352
+ }
2353
+ target.dispatchEvent(event);
2354
+ return;
2355
+ }
2356
+ const target = defaultCommunicationTarget(this);
2357
+ if (!target) {
2358
+ throw new TypeError(`@dispatch('${eventName}') requires an element, attached controller, or provided @daemon instance.`);
2359
+ }
2360
+ target.dispatchEvent(event);
2361
+ };
2362
+ // Helper to handle timed dispatch
2363
+ const timedDispatch = (detail) => {
2364
+ if (!isDispatchGenerationActive(this, generation))
2365
+ return;
2366
+ if (debounceDelay && debounceDelay > 0) {
2367
+ timers.debounceTimeout = setTimeout(() => {
2368
+ timers.debounceTimeout = null;
2369
+ if (isDispatchGenerationActive(this, generation) && timers.invocation === invocation) {
2370
+ doDispatch(detail);
2371
+ }
2372
+ }, debounceDelay);
2373
+ return;
2374
+ }
2375
+ if (!throttleDelay || throttleDelay <= 0) {
2376
+ doDispatch(detail);
2377
+ return;
2378
+ }
2379
+ const now = Date.now();
2380
+ const remaining = throttleDelay - (now - timers.throttleLastCall);
2381
+ if (remaining <= 0) {
2382
+ clearTimeout(timers.throttleTimeout);
2383
+ timers.throttleLastCall = now;
2384
+ doDispatch(detail);
2385
+ return;
2386
+ }
2387
+ // Record the LATEST detail so the trailing dispatch carries fresh
2388
+ // data, not the first-suppressed-call detail captured by closure.
2389
+ timers.latestDetail = detail;
2390
+ // Always schedule from the last actual dispatch using the interval
2391
+ // resolved for THIS invocation. A later invocation can therefore
2392
+ // lengthen or shorten the pending trailing window coherently.
2393
+ timers.throttleTimeout = setTimeout(() => {
2394
+ timers.throttleLastCall = Date.now();
2395
+ timers.throttleTimeout = null;
2396
+ const d = timers.latestDetail;
2397
+ timers.latestDetail = undefined;
2398
+ if (isDispatchGenerationActive(this, generation) && timers.invocation === invocation) {
2399
+ doDispatch(d);
2400
+ }
2401
+ }, remaining);
2402
+ };
2403
+ // Handle async methods
2404
+ if (result instanceof Promise) {
2405
+ return result.then((resolvedResult) => {
2406
+ const usesDeferredTiming = (debounceDelay ?? 0) > 0 || (throttleDelay ?? 0) > 0;
2407
+ if (isDispatchGenerationActive(this, generation)
2408
+ && (!usesDeferredTiming || timers.invocation === invocation)) {
2409
+ timedDispatch(resolvedResult);
2410
+ }
2411
+ return resolvedResult;
2412
+ });
2413
+ }
2414
+ // Sync method
2415
+ timedDispatch(result);
2416
+ return result;
2417
+ };
2418
+ };
2419
+ }
2420
+ /**
2421
+ * Clear any pending debounce/throttle dispatch timers on an instance (e.g. on
2422
+ * disconnect, so a queued event doesn't fire into a detached node). A dispatch
2423
+ * is a one-shot signal, so pending ones are dropped, not replayed.
2424
+ */
2425
+ function clearDispatchTimers(instance) {
2426
+ const generation = (instance[DISPATCH_GENERATION] ?? 0) + 1;
2427
+ instance[DISPATCH_GENERATION] = generation;
2428
+ const timers = instance[DISPATCH_TIMERS];
2429
+ if (!timers)
2430
+ return;
2431
+ for (const t of timers.values()) {
2432
+ if (t.debounceTimeout)
2433
+ clearTimeout(t.debounceTimeout);
2434
+ if (t.throttleTimeout)
2435
+ clearTimeout(t.throttleTimeout);
2436
+ t.debounceTimeout = null;
2437
+ t.throttleTimeout = null;
2438
+ t.throttleLastCall = 0;
2439
+ t.latestDetail = undefined;
2440
+ t.invocation = (t.invocation ?? 0) + 1;
2441
+ }
2442
+ }
2443
+
2148
2444
  /**
2149
2445
  * @context decorator for receiving router context updates
2150
2446
  */
@@ -2683,6 +2979,9 @@ async function detachController(element) {
2683
2979
  if (!controllerInstance) {
2684
2980
  return;
2685
2981
  }
2982
+ // Invalidate dispatch work before every teardown path, including an attach
2983
+ // that is still waiting for host readiness and will return early below.
2984
+ clearDispatchTimers(controllerInstance);
2686
2985
  // Claim the controller immediately — before any await — so a second detach
2687
2986
  // that overlaps this one (e.g. disconnect's fire-and-forget detach racing a
2688
2987
  // controller reassignment) finds no instance and returns, instead of running
@@ -2715,6 +3014,8 @@ async function detachController(element) {
2715
3014
  else {
2716
3015
  await controllerInstance.detach(element);
2717
3016
  }
3017
+ // detach() itself may have invoked a decorated dispatch method.
3018
+ clearDispatchTimers(controllerInstance);
2718
3019
  controllerInstance.element = null;
2719
3020
  // Cleanup @observe observers for controller
2720
3021
  cleanupObservers(controllerInstance);
@@ -3074,36 +3375,184 @@ function findRenderHost(node) {
3074
3375
  return null;
3075
3376
  }
3076
3377
 
3077
- // Unique marker for dynamic parts
3078
- // This parses as a comment node but doesn't get escaped in attributes
3079
- const marker = `snice$${Math.random().toFixed(9).slice(2)}$`;
3080
- const markerMatch = '?' + marker;
3378
+ const SAFE_TAG = /^[a-z][a-z0-9._-]{0,127}$/;
3379
+ const SAFE_CLASS = /^[A-Za-z_$][A-Za-z0-9_$]{0,127}$/;
3380
+ const AMBIGUOUS_RENDER_HOST_IDENTITY = Symbol('ambiguous-render-host-identity');
3381
+ // Keys are weak; values contain only a sanitized label or the ambiguity
3382
+ // sentinel. The same frozen identity is recorded for the exact constructor
3383
+ // and its exact prototype only after the caller verifies registration.
3384
+ const registeredRenderHosts = new WeakMap();
3385
+ function registerRenderHostIdentity(constructor, tagName) {
3386
+ try {
3387
+ if (typeof constructor !== 'function' || !SAFE_TAG.test(tagName))
3388
+ return;
3389
+ const prototypeDescriptor = Object.getOwnPropertyDescriptor(constructor, 'prototype');
3390
+ const prototype = prototypeDescriptor && 'value' in prototypeDescriptor
3391
+ ? prototypeDescriptor.value
3392
+ : undefined;
3393
+ if ((typeof prototype !== 'object' && typeof prototype !== 'function') || prototype === null)
3394
+ return;
3395
+ const nameDescriptor = Object.getOwnPropertyDescriptor(constructor, 'name');
3396
+ const name = nameDescriptor && 'value' in nameDescriptor && typeof nameDescriptor.value === 'string'
3397
+ ? nameDescriptor.value
3398
+ : '';
3399
+ const className = SAFE_CLASS.test(name) ? name : '';
3400
+ const identity = Object.freeze({
3401
+ label: className ? `<${tagName}> (${className})` : `<${tagName}>`,
3402
+ });
3403
+ const constructorState = registeredRenderHosts.get(constructor);
3404
+ const prototypeState = registeredRenderHosts.get(prototype);
3405
+ const existingStates = [constructorState, prototypeState].filter((state) => state !== undefined);
3406
+ const ambiguous = existingStates.some(state => state === AMBIGUOUS_RENDER_HOST_IDENTITY || state.label !== identity.label);
3407
+ const nextState = ambiguous
3408
+ ? AMBIGUOUS_RENDER_HOST_IDENTITY
3409
+ : existingStates[0] ?? identity;
3410
+ // Update both exact keys in the same synchronous registration call. Once
3411
+ // either key is ambiguous, no later registration can restore attribution.
3412
+ registeredRenderHosts.set(constructor, nextState);
3413
+ registeredRenderHosts.set(prototype, nextState);
3414
+ }
3415
+ catch {
3416
+ // Registration diagnostics are best-effort and must not affect the
3417
+ // successful custom-elements registration that already occurred.
3418
+ }
3419
+ }
3420
+ function registeredIdentity(host) {
3421
+ const prototype = Object.getPrototypeOf(host);
3422
+ if (!prototype)
3423
+ return undefined;
3424
+ const constructorDescriptor = Object.getOwnPropertyDescriptor(prototype, 'constructor');
3425
+ const constructor = constructorDescriptor && 'value' in constructorDescriptor
3426
+ ? constructorDescriptor.value
3427
+ : undefined;
3428
+ if (typeof constructor !== 'function')
3429
+ return undefined;
3430
+ const prototypeIdentity = registeredRenderHosts.get(prototype);
3431
+ const constructorIdentity = registeredRenderHosts.get(constructor);
3432
+ return prototypeIdentity !== AMBIGUOUS_RENDER_HOST_IDENTITY &&
3433
+ prototypeIdentity === constructorIdentity
3434
+ ? prototypeIdentity
3435
+ : undefined;
3436
+ }
3437
+ /** Capture once at the render boundary; failures fall back without masking the render error. */
3438
+ function captureRenderHostIdentity(host) {
3439
+ let label = '<element>';
3440
+ try {
3441
+ const registration = registeredIdentity(host);
3442
+ if (registration)
3443
+ label = registration.label;
3444
+ }
3445
+ catch {
3446
+ // Host/prototype objects can be proxied. Attribution is best-effort
3447
+ // and must never replace the original render or template error.
3448
+ }
3449
+ return Object.freeze({ label, marker: Symbol('render-host-identity') });
3450
+ }
3451
+ class ContextualRenderError extends Error {
3452
+ #identityMarker;
3453
+ constructor(identity, message, cause) {
3454
+ super(message, { cause });
3455
+ this.#identityMarker = identity.marker;
3456
+ }
3457
+ belongsTo(identity) {
3458
+ return this.#identityMarker === identity.marker;
3459
+ }
3460
+ }
3461
+ function contextualizeRenderError(identity, error) {
3462
+ if (error instanceof ContextualRenderError && error.belongsTo(identity))
3463
+ return error;
3464
+ const message = error instanceof Error ? error.message : String(error);
3465
+ const contextual = new ContextualRenderError(identity, `snice: render failed for ${identity.label}: ${message}`, error);
3466
+ if (error instanceof Error)
3467
+ contextual.name = error.name;
3468
+ return contextual;
3469
+ }
3470
+
3471
+ // Unique marker for dynamic template parts. It parses as a comment node but
3472
+ // does not get escaped in attributes.
3473
+ const templateMarker = `snice$${Math.random().toFixed(9).slice(2)}$`;
3474
+ const templateMarkerMatch = '?' + templateMarker;
3081
3475
  // A true comment, not a processing instruction (<?...>): in HTML content both
3082
3476
  // parse to the same comment node, but PIs are dropped inside foreign content
3083
3477
  // (<svg>), which would silently kill any node binding inside an svg block.
3084
- const nodeMarker = `<!--${markerMatch}-->`;
3478
+ const templateNodeMarker = `<!--${templateMarkerMatch}-->`;
3085
3479
  // Escape the `$` chars — as a bare RegExp they'd be end anchors and the
3086
3480
  // pattern would never match, so marker-bearing text/comments never split.
3087
- const markerRegex = new RegExp(marker.replace(/\$/g, '\\$'), 'g');
3481
+ const templateMarkerRegex = new RegExp(templateMarker.replace(/\$/g, '\\$'), 'g');
3482
+ function hasTemplateMarker(value) {
3483
+ return value?.includes(templateMarker) ?? false;
3484
+ }
3485
+
3088
3486
  // Template cache - templates with same string array can be reused
3089
3487
  const templateCache = new WeakMap();
3090
3488
  // Sentinel for "not yet set" - distinct from undefined/null
3091
3489
  const NOT_COMMITTED = Symbol('not-committed');
3092
- function markPreUpgradePropertyBinding(element, propertyName) {
3490
+ const pendingDefinitionRegistries = new WeakMap();
3491
+ function isUpgradedBy(element, constructor) {
3492
+ return Object.prototype.isPrototypeOf.call(constructor.prototype, element);
3493
+ }
3494
+ function flushPreUpgradePropertyBindings(element, registry) {
3495
+ const tagName = element.localName;
3496
+ const constructor = tagName && registry?.get(tagName);
3497
+ if (!constructor || !isUpgradedBy(element, constructor))
3498
+ return;
3499
+ const target = element;
3500
+ const markedBindings = target[PRE_UPGRADE_PROPERTY_BINDINGS];
3501
+ if (!markedBindings?.size)
3502
+ return;
3503
+ for (const propertyName of Array.from(markedBindings)) {
3504
+ // A Snice @property initializer consumes its own marker and expando while
3505
+ // the element upgrades. Anything left belongs to a class that retained
3506
+ // the native/custom property channel, so replay the latest parked value
3507
+ // through that now-upgraded channel.
3508
+ if (Object.prototype.hasOwnProperty.call(target, propertyName)) {
3509
+ const value = target[propertyName];
3510
+ delete target[propertyName];
3511
+ target[propertyName] = value;
3512
+ }
3513
+ markedBindings.delete(propertyName);
3514
+ }
3515
+ delete target[PRE_UPGRADE_PROPERTY_BINDINGS];
3516
+ }
3517
+ function schedulePreUpgradePropertyFlush(element, registry) {
3518
+ if (!registry?.whenDefined || pendingDefinitionRegistries.get(element) === registry)
3519
+ return;
3520
+ pendingDefinitionRegistries.set(element, registry);
3521
+ const reference = new WeakRef(element);
3522
+ void registry.whenDefined(element.localName).then(() => {
3523
+ const target = reference.deref();
3524
+ if (target)
3525
+ flushPreUpgradePropertyBindings(target, registry);
3526
+ });
3527
+ }
3528
+ function markPreUpgradePropertyBinding(element, propertyName, registry) {
3093
3529
  const tagName = element.localName;
3094
3530
  if (!tagName?.includes('-'))
3095
3531
  return false;
3096
- const registry = element.ownerDocument?.defaultView?.customElements ?? globalThis.customElements;
3097
- if (registry?.get(tagName))
3098
- return false;
3532
+ const registeredConstructor = registry?.get(tagName);
3533
+ if (registeredConstructor) {
3534
+ // Template contents can still contain an unupgraded HTMLElement after the
3535
+ // definition has been registered. Registry presence alone therefore does
3536
+ // not prove that the reactive accessor exists on this clone yet.
3537
+ const isUpgraded = isUpgradedBy(element, registeredConstructor);
3538
+ if (isUpgraded)
3539
+ return false;
3540
+ // A registered non-Snice element (or a Snice element that did not
3541
+ // redeclare this member) must retain the native IDL channel. Parking its
3542
+ // role/title/etc. would replace native reflection with an own expando.
3543
+ const reactiveProperties = registeredConstructor[PROPERTIES];
3544
+ if (!reactiveProperties?.has(propertyName))
3545
+ return false;
3546
+ }
3099
3547
  const target = element;
3100
3548
  if (!target[PRE_UPGRADE_PROPERTY_BINDINGS])
3101
3549
  target[PRE_UPGRADE_PROPERTY_BINDINGS] = new Set();
3102
3550
  target[PRE_UPGRADE_PROPERTY_BINDINGS].add(propertyName);
3551
+ schedulePreUpgradePropertyFlush(element, registry);
3103
3552
  return true;
3104
3553
  }
3105
- function commitPropertyValue(element, propertyName, value) {
3106
- if (!markPreUpgradePropertyBinding(element, propertyName)) {
3554
+ function commitPropertyValue(element, propertyName, value, registry) {
3555
+ if (!markPreUpgradePropertyBinding(element, propertyName, registry)) {
3107
3556
  element[propertyName] = value;
3108
3557
  return;
3109
3558
  }
@@ -3203,7 +3652,7 @@ class Template {
3203
3652
  if (tagName === 'if') {
3204
3653
  // <if value="${condition}">children</if>
3205
3654
  const valueAttr = element.getAttribute('value');
3206
- if (valueAttr && valueAttr.includes(marker)) {
3655
+ if (valueAttr && valueAttr.includes(templateMarker)) {
3207
3656
  // Remove the value attribute
3208
3657
  element.removeAttribute('value');
3209
3658
  this.parts.push({
@@ -3222,7 +3671,7 @@ class Template {
3222
3671
  if (tagName === 'case') {
3223
3672
  // <case value="${value}">children</case>
3224
3673
  const valueAttr = element.getAttribute('value');
3225
- if (valueAttr && valueAttr.includes(marker)) {
3674
+ if (valueAttr && valueAttr.includes(templateMarker)) {
3226
3675
  for (const child of Array.from(element.childNodes)) {
3227
3676
  if (child.nodeType === Node.ELEMENT_NODE) {
3228
3677
  const branchTag = child.tagName.toLowerCase();
@@ -3231,7 +3680,7 @@ class Template {
3231
3680
  }
3232
3681
  }
3233
3682
  else if (child.nodeType === Node.TEXT_NODE && child.textContent?.trim() ||
3234
- child.nodeType === Node.COMMENT_NODE && child.data.includes(marker)) {
3683
+ child.nodeType === Node.COMMENT_NODE && child.data.includes(templateMarker)) {
3235
3684
  throw new Error('snice: content inside <case> must be nested in a <when> or <default> branch.');
3236
3685
  }
3237
3686
  }
@@ -3251,7 +3700,7 @@ class Template {
3251
3700
  }
3252
3701
  if (tagName === 'else-if') {
3253
3702
  const valueAttr = element.getAttribute('value');
3254
- if (valueAttr && valueAttr.includes(marker)) {
3703
+ if (valueAttr && valueAttr.includes(templateMarker)) {
3255
3704
  element.removeAttribute('value');
3256
3705
  this.parts.push({
3257
3706
  type: 'conditional-else-if',
@@ -3266,7 +3715,7 @@ class Template {
3266
3715
  }
3267
3716
  if (tagName === 'when') {
3268
3717
  const valueAttr = element.getAttribute('value');
3269
- if (valueAttr && valueAttr.includes(marker)) {
3718
+ if (valueAttr && valueAttr.includes(templateMarker)) {
3270
3719
  element.removeAttribute('value');
3271
3720
  this.parts.push({
3272
3721
  type: 'conditional-when',
@@ -3283,12 +3732,12 @@ class Template {
3283
3732
  const attr = attributes[i];
3284
3733
  const value = attr.value;
3285
3734
  // Check for attribute bindings
3286
- if (value.includes(marker)) {
3735
+ if (value.includes(templateMarker)) {
3287
3736
  attrsToRemove.push(attr);
3288
3737
  // Get original attribute name with preserved case
3289
3738
  const originalName = attrNamesForParts[partIndex] || attr.name;
3290
3739
  // Extract static string segments by splitting on marker
3291
- const attrStrings = value.split(marker);
3740
+ const attrStrings = value.split(templateMarker);
3292
3741
  // Number of expressions = number of markers = attrStrings.length - 1
3293
3742
  const expressionCount = attrStrings.length - 1;
3294
3743
  // Property/boolean/event bindings take a SINGLE expression — they
@@ -3429,7 +3878,7 @@ class Template {
3429
3878
  else if (node.nodeType === Node.COMMENT_NODE) {
3430
3879
  const comment = node;
3431
3880
  // Check for marker match (processing instruction becomes comment)
3432
- if (comment.data === markerMatch) {
3881
+ if (comment.data === templateMarkerMatch) {
3433
3882
  // Node part
3434
3883
  const parent = comment.parentNode;
3435
3884
  const endNode = document.createComment('');
@@ -3441,11 +3890,11 @@ class Template {
3441
3890
  endNode
3442
3891
  });
3443
3892
  }
3444
- else if (comment.data.includes(marker)) {
3893
+ else if (comment.data.includes(templateMarker)) {
3445
3894
  // Binding(s) inside an authored HTML comment (<!-- ${x} -->).
3446
3895
  // Render values into the comment text; critically, consume the
3447
3896
  // right number of value indices so later parts stay aligned.
3448
- const commentStrings = comment.data.split(markerRegex);
3897
+ const commentStrings = comment.data.split(templateMarkerRegex);
3449
3898
  this.parts.push({
3450
3899
  type: 'comment',
3451
3900
  index: partIndex,
@@ -3458,10 +3907,10 @@ class Template {
3458
3907
  else if (node.nodeType === Node.TEXT_NODE) {
3459
3908
  const text = node;
3460
3909
  const data = text.data;
3461
- if (data.includes(marker)) {
3910
+ if (data.includes(templateMarker)) {
3462
3911
  // Split text node at markers
3463
3912
  const parent = text.parentNode;
3464
- const parts = data.split(markerRegex);
3913
+ const parts = data.split(templateMarkerRegex);
3465
3914
  const lastIndex = parts.length - 1;
3466
3915
  for (let i = 0; i < lastIndex; i++) {
3467
3916
  parent.insertBefore(document.createTextNode(parts[i]), text);
@@ -3583,13 +4032,13 @@ function prepareTemplate(result) {
3583
4032
  // the value index stays aligned (a swallowed marker would shift every
3584
4033
  // binding after the comment).
3585
4034
  attrNamesForParts.push('');
3586
- htmlParts.push(marker);
4035
+ htmlParts.push(templateMarker);
3587
4036
  }
3588
4037
  else if (inAttrValue) {
3589
4038
  // We're inside a quoted attribute value - this is an attribute binding
3590
4039
  // For subsequent interpolations in same attribute, keep using same attr name
3591
4040
  attrNamesForParts.push(currentAttrName);
3592
- htmlParts.push(marker);
4041
+ htmlParts.push(templateMarker);
3593
4042
  }
3594
4043
  else if (inTag) {
3595
4044
  // Inside tag but not in attr value - check for special cases
@@ -3604,7 +4053,7 @@ function prepareTemplate(result) {
3604
4053
  }
3605
4054
  currentAttrName = trimmed.substring(attrStart + 1, trimmed.length - 1).trim();
3606
4055
  attrNamesForParts.push(currentAttrName);
3607
- htmlParts.push(marker);
4056
+ htmlParts.push(templateMarker);
3608
4057
  }
3609
4058
  else {
3610
4059
  // Check if this is a meta element (<if> or <case>)
@@ -3612,7 +4061,7 @@ function prepareTemplate(result) {
3612
4061
  if (metaElementMatch) {
3613
4062
  currentAttrName = 'value';
3614
4063
  attrNamesForParts.push('value');
3615
- htmlParts.push(`value="${marker}"`);
4064
+ htmlParts.push(`value="${templateMarker}"`);
3616
4065
  }
3617
4066
  else {
3618
4067
  const nearbyTemplate = (str.slice(-40) + '${…}' + (strings[i + 1] ?? '').slice(0, 40)).replace(/\s+/g, ' ').trim();
@@ -3625,7 +4074,7 @@ function prepareTemplate(result) {
3625
4074
  else {
3626
4075
  // Outside any tag - this is node content
3627
4076
  attrNamesForParts.push('');
3628
- htmlParts.push(nodeMarker);
4077
+ htmlParts.push(templateNodeMarker);
3629
4078
  }
3630
4079
  }
3631
4080
  }
@@ -3659,22 +4108,37 @@ function prepareTemplate(result) {
3659
4108
  * Extract the key of a list item rendered from a template with a
3660
4109
  * `key=${...}` binding. Returns undefined for unkeyed items.
3661
4110
  */
3662
- function getItemKey(item) {
4111
+ function getItemKey(item, renderIdentity) {
3663
4112
  if (!isTemplateResult(item))
3664
4113
  return undefined;
3665
- const tmpl = prepareTemplate(item);
4114
+ let tmpl;
4115
+ try {
4116
+ tmpl = prepareTemplate(item);
4117
+ }
4118
+ catch (error) {
4119
+ throw renderIdentity ? contextualizeRenderError(renderIdentity, error) : error;
4120
+ }
3666
4121
  return tmpl.keyIndex === -1 ? undefined : item.values[tmpl.keyIndex];
3667
4122
  }
3668
4123
  /**
3669
4124
  * Instance of a rendered template
3670
4125
  */
3671
4126
  class TemplateInstance {
3672
- constructor(result) {
4127
+ #renderIdentity;
4128
+ #customElementRegistry;
4129
+ constructor(result, renderIdentity = null, customElementRegistry = document.defaultView?.customElements ?? globalThis.customElements ?? null) {
3673
4130
  this.parts = [];
3674
4131
  this.fragment = null;
3675
4132
  this.conditionalParts = []; // if/case parts with their indices
3676
4133
  this.regularParts = []; // all other parts with their indices
3677
- this.template = prepareTemplate(result);
4134
+ this.#renderIdentity = renderIdentity;
4135
+ this.#customElementRegistry = customElementRegistry;
4136
+ try {
4137
+ this.template = prepareTemplate(result);
4138
+ }
4139
+ catch (error) {
4140
+ throw renderIdentity ? contextualizeRenderError(renderIdentity, error) : error;
4141
+ }
3678
4142
  this.strings = result.strings;
3679
4143
  }
3680
4144
  /**
@@ -3684,6 +4148,10 @@ class TemplateInstance {
3684
4148
  isSameTemplate(strings) {
3685
4149
  return this.strings === strings;
3686
4150
  }
4151
+ /** Internal attribution value; intentionally contains no live renderer host. */
4152
+ getRenderHostIdentity() {
4153
+ return this.#renderIdentity;
4154
+ }
3687
4155
  renderFragment() {
3688
4156
  if (!this.fragment) {
3689
4157
  // First render - clone template and create parts
@@ -3712,7 +4180,7 @@ class TemplateInstance {
3712
4180
  case 'node':
3713
4181
  const startNode = nodeMap.get(partDef.startNode);
3714
4182
  const endNode = nodeMap.get(partDef.endNode);
3715
- part = new NodePart(startNode, endNode);
4183
+ part = new NodePart(startNode, endNode, this.#renderIdentity, this.#customElementRegistry);
3716
4184
  break;
3717
4185
  case 'attribute':
3718
4186
  const attrElement = nodeMap.get(partDef.element);
@@ -3720,7 +4188,7 @@ class TemplateInstance {
3720
4188
  break;
3721
4189
  case 'property':
3722
4190
  const propElement = nodeMap.get(partDef.element);
3723
- part = new PropertyPart(propElement, partDef.name);
4191
+ part = new PropertyPart(propElement, partDef.name, this.#customElementRegistry);
3724
4192
  break;
3725
4193
  case 'boolean-attribute':
3726
4194
  const boolElement = nodeMap.get(partDef.element);
@@ -3740,7 +4208,7 @@ class TemplateInstance {
3740
4208
  break;
3741
4209
  case 'spread':
3742
4210
  const spreadElement = nodeMap.get(partDef.element);
3743
- part = new SpreadPart(spreadElement, partDef.name);
4211
+ part = new SpreadPart(spreadElement, partDef.name, this.#customElementRegistry);
3744
4212
  break;
3745
4213
  case 'controller':
3746
4214
  const controllerElement = nodeMap.get(partDef.element);
@@ -3919,7 +4387,9 @@ class Part {
3919
4387
  * - Reuses child NodeParts when rendering arrays
3920
4388
  */
3921
4389
  class NodePart extends Part {
3922
- constructor(startNode, endNode) {
4390
+ #renderIdentity;
4391
+ #customElementRegistry;
4392
+ constructor(startNode, endNode, renderIdentity = null, customElementRegistry = null) {
3923
4393
  super();
3924
4394
  this.type = 'node';
3925
4395
  this._committedValue = NOT_COMMITTED;
@@ -3934,6 +4404,11 @@ class NodePart extends Part {
3934
4404
  this._committingAsyncValue = false;
3935
4405
  this.startNode = startNode;
3936
4406
  this.endNode = endNode;
4407
+ this.#renderIdentity = renderIdentity;
4408
+ this.#customElementRegistry = customElementRegistry;
4409
+ }
4410
+ _withRenderContext(error) {
4411
+ return this.#renderIdentity ? contextualizeRenderError(this.#renderIdentity, error) : error;
3937
4412
  }
3938
4413
  commit(value) {
3939
4414
  // Handle noChange sentinel
@@ -4042,7 +4517,7 @@ class NodePart extends Part {
4042
4517
  // Different template or first render: prepare and validate the replacement
4043
4518
  // while it is detached. If a binding throws, the currently
4044
4519
  // committed range remains intact and its lifecycle stays connected.
4045
- const instance = new TemplateInstance(result);
4520
+ const instance = new TemplateInstance(result, this.#renderIdentity, this.#customElementRegistry);
4046
4521
  const fragment = instance.renderFragment();
4047
4522
  try {
4048
4523
  instance.update(result.values);
@@ -4093,7 +4568,9 @@ class NodePart extends Part {
4093
4568
  */
4094
4569
  _commitIterable(value, explicitKeys) {
4095
4570
  const items = Array.isArray(value) ? value : Array.from(value);
4096
- const newKeys = explicitKeys ? Array.from(explicitKeys) : items.map(getItemKey);
4571
+ const newKeys = explicitKeys
4572
+ ? Array.from(explicitKeys)
4573
+ : items.map(item => getItemKey(item, this.#renderIdentity));
4097
4574
  if (newKeys.length !== items.length) {
4098
4575
  throw new Error('snice: keyed iterable produced a different number of keys and values.');
4099
4576
  }
@@ -4139,7 +4616,7 @@ class NodePart extends Part {
4139
4616
  const endMarker = document.createComment('');
4140
4617
  this._insertBefore(startMarker);
4141
4618
  this._insertBefore(endMarker);
4142
- itemPart = new NodePart(startMarker, endMarker);
4619
+ itemPart = new NodePart(startMarker, endMarker, this.#renderIdentity, this.#customElementRegistry);
4143
4620
  itemParts.push(itemPart);
4144
4621
  }
4145
4622
  else {
@@ -4243,7 +4720,7 @@ class NodePart extends Part {
4243
4720
  const endMarker = document.createComment('');
4244
4721
  parent.insertBefore(startMarker, ref);
4245
4722
  parent.insertBefore(endMarker, ref);
4246
- part = new NodePart(startMarker, endMarker);
4723
+ part = new NodePart(startMarker, endMarker, this.#renderIdentity, this.#customElementRegistry);
4247
4724
  newParts[i] = part;
4248
4725
  }
4249
4726
  part.commit(items[i]);
@@ -4315,7 +4792,7 @@ class NodePart extends Part {
4315
4792
  catch (error) {
4316
4793
  this._asyncRunning = false;
4317
4794
  this._asyncCompleted = true;
4318
- console.error('snice: async iterable template value failed:', error);
4795
+ console.error('snice: async iterable template value failed:', this._withRenderContext(error));
4319
4796
  return;
4320
4797
  }
4321
4798
  void (async () => {
@@ -4328,8 +4805,9 @@ class NodePart extends Part {
4328
4805
  }
4329
4806
  }
4330
4807
  catch (error) {
4331
- if (version === this._asyncVersion)
4332
- console.error('snice: async iterable template value failed:', error);
4808
+ if (version === this._asyncVersion) {
4809
+ console.error('snice: async iterable template value failed:', this._withRenderContext(error));
4810
+ }
4333
4811
  }
4334
4812
  finally {
4335
4813
  if (version === this._asyncVersion) {
@@ -4350,14 +4828,14 @@ class NodePart extends Part {
4350
4828
  this._commitAsyncValue(value);
4351
4829
  }
4352
4830
  catch (error) {
4353
- console.error('snice: promise template value failed:', error);
4831
+ console.error('snice: promise template value failed:', this._withRenderContext(error));
4354
4832
  }
4355
4833
  }, error => {
4356
4834
  if (version !== this._asyncVersion)
4357
4835
  return;
4358
4836
  this._asyncRunning = false;
4359
4837
  this._asyncCompleted = true;
4360
- console.error('snice: promise template value failed:', error);
4838
+ console.error('snice: promise template value failed:', this._withRenderContext(error));
4361
4839
  });
4362
4840
  }
4363
4841
  _commitAsyncValue(value) {
@@ -4725,7 +5203,8 @@ function validateSpreadEvents(next, label) {
4725
5203
  }
4726
5204
  /** Named spreads: ...props, ...attrs, and ...events. */
4727
5205
  class SpreadPart extends Part {
4728
- constructor(element, name) {
5206
+ #customElementRegistry;
5207
+ constructor(element, name, customElementRegistry = null) {
4729
5208
  super();
4730
5209
  this.type = 'spread';
4731
5210
  this.committed = {};
@@ -4733,6 +5212,7 @@ class SpreadPart extends Part {
4733
5212
  this.consumedOnce = new Map();
4734
5213
  this.element = element;
4735
5214
  this.name = name;
5215
+ this.#customElementRegistry = customElementRegistry;
4736
5216
  }
4737
5217
  commit(value) {
4738
5218
  if (value === noChange)
@@ -4754,13 +5234,13 @@ class SpreadPart extends Part {
4754
5234
  commitProperties(next) {
4755
5235
  for (const key of Object.keys(this.committed)) {
4756
5236
  if (!Object.prototype.hasOwnProperty.call(next, key)) {
4757
- commitPropertyValue(this.element, key, undefined);
5237
+ commitPropertyValue(this.element, key, undefined, this.#customElementRegistry);
4758
5238
  }
4759
5239
  }
4760
5240
  for (const [key, value] of Object.entries(next)) {
4761
5241
  if (Object.is(this.committed[key], value))
4762
5242
  continue;
4763
- commitPropertyValue(this.element, key, value === nothing ? undefined : value);
5243
+ commitPropertyValue(this.element, key, value === nothing ? undefined : value, this.#customElementRegistry);
4764
5244
  }
4765
5245
  }
4766
5246
  commitAttributes(next) {
@@ -4844,6 +5324,9 @@ class SpreadPart extends Part {
4844
5324
  this.detachListeners();
4845
5325
  }
4846
5326
  reconnected() {
5327
+ if (this.name === 'props' || this.name === 'properties') {
5328
+ flushPreUpgradePropertyBindings(this.element, this.#customElementRegistry);
5329
+ }
4847
5330
  if (this.name === 'events' && this.listeners.size === 0)
4848
5331
  this.commitEvents(this.committed);
4849
5332
  }
@@ -4858,12 +5341,14 @@ class SpreadPart extends Part {
4858
5341
  * PropertyPart handles property bindings (.property=${value})
4859
5342
  */
4860
5343
  class PropertyPart extends Part {
4861
- constructor(element, name) {
5344
+ #customElementRegistry;
5345
+ constructor(element, name, customElementRegistry = null) {
4862
5346
  super();
4863
5347
  this.type = 'property';
4864
5348
  this._committedValue = NOT_COMMITTED;
4865
5349
  this.element = element;
4866
5350
  this.name = name;
5351
+ this.#customElementRegistry = customElementRegistry;
4867
5352
  }
4868
5353
  commit(value) {
4869
5354
  if (value === noChange)
@@ -4887,7 +5372,10 @@ class PropertyPart extends Part {
4887
5372
  return;
4888
5373
  }
4889
5374
  this._committedValue = value;
4890
- commitPropertyValue(this.element, this.name, value === nothing ? undefined : value);
5375
+ commitPropertyValue(this.element, this.name, value === nothing ? undefined : value, this.#customElementRegistry);
5376
+ }
5377
+ reconnected() {
5378
+ flushPreUpgradePropertyBindings(this.element, this.#customElementRegistry);
4891
5379
  }
4892
5380
  clear() {
4893
5381
  // This part is being destroyed with its owning template. The element is
@@ -5607,16 +6095,6 @@ function resetHostAutofocus(host) {
5607
6095
  * Production default is false (log and keep the previous DOM).
5608
6096
  */
5609
6097
  let strictRenderErrors = false;
5610
- function contextualizeRenderError(element, error) {
5611
- const tag = element.tagName?.toLowerCase() || 'element';
5612
- const message = error instanceof Error ? error.message : String(error);
5613
- const contextual = new Error(`snice: render failed for <${tag}>: ${message}`, {
5614
- cause: error
5615
- });
5616
- if (error instanceof Error)
5617
- contextual.name = error.name;
5618
- return contextual;
5619
- }
5620
6098
  function setStrictRenderErrors(value) {
5621
6099
  strictRenderErrors = value;
5622
6100
  }
@@ -5719,6 +6197,8 @@ const MAX_RENDER_DEPTH = 50;
5719
6197
  * Perform the actual render of an element
5720
6198
  */
5721
6199
  function performRender(element, options, precomputedResult) {
6200
+ const capturedIdentity = captureRenderHostIdentity(element);
6201
+ let renderIdentity = capturedIdentity;
5722
6202
  const hasPrecomputedResult = arguments.length >= 3;
5723
6203
  const renderMethod = element[RENDER_METHOD];
5724
6204
  if (!renderMethod) {
@@ -5774,12 +6254,16 @@ function performRender(element, options, precomputedResult) {
5774
6254
  }
5775
6255
  let instance = element[RENDER_INSTANCE];
5776
6256
  if (instance && instance.isSameTemplate(result.strings)) {
6257
+ renderIdentity = instance.getRenderHostIdentity() ?? capturedIdentity;
5777
6258
  instance.update(result.values);
5778
6259
  flushRenderCallbacks(element);
5779
6260
  scheduleAutofocus(element);
5780
6261
  return;
5781
6262
  }
5782
- const nextInstance = new TemplateInstance(result);
6263
+ const customElementRegistry = renderRoot instanceof ShadowRoot
6264
+ ? renderRoot.customElementRegistry ?? renderRoot.ownerDocument.defaultView?.customElements ?? null
6265
+ : renderRoot.ownerDocument.defaultView?.customElements ?? null;
6266
+ const nextInstance = new TemplateInstance(result, capturedIdentity, customElementRegistry);
5783
6267
  const nextFragment = nextInstance.renderFragment();
5784
6268
  // Commit while detached first. A malformed binding can then fail without
5785
6269
  // exposing a half-updated tree. Connection-aware parts are paired with
@@ -5839,7 +6323,7 @@ function performRender(element, options, precomputedResult) {
5839
6323
  throw cleanupError;
5840
6324
  }
5841
6325
  catch (error) {
5842
- const contextual = contextualizeRenderError(element, error);
6326
+ const contextual = contextualizeRenderError(renderIdentity, error);
5843
6327
  if (strictRenderErrors)
5844
6328
  throw contextual;
5845
6329
  console.error('Error rendering element:', contextual);
@@ -6127,134 +6611,6 @@ function applyStyles(element) {
6127
6611
  }
6128
6612
  }
6129
6613
 
6130
- // @dispatch decorator - auto-dispatches custom events from method return values
6131
- /**
6132
- * Decorator that automatically dispatches a custom event after a method is called.
6133
- * The return value of the method becomes the event detail.
6134
- *
6135
- * @param eventName The name of the event to dispatch
6136
- * @param options Optional configuration extending EventInit
6137
- */
6138
- function dispatch(eventName, options) {
6139
- return function (originalMethod, _context) {
6140
- return function (...args) {
6141
- // Create timing wrappers for dispatch (per-instance)
6142
- if (!this[DISPATCH_TIMERS]) {
6143
- this[DISPATCH_TIMERS] = new Map();
6144
- }
6145
- const timerKey = `${eventName}_${_context.name}`;
6146
- if (!this[DISPATCH_TIMERS].has(timerKey)) {
6147
- this[DISPATCH_TIMERS].set(timerKey, {
6148
- debounceTimeout: null,
6149
- throttleLastCall: 0,
6150
- throttleTimeout: null
6151
- });
6152
- }
6153
- const timers = this[DISPATCH_TIMERS].get(timerKey);
6154
- // Call the original method with preserved this context
6155
- const result = originalMethod.apply(this, args);
6156
- // Helper to dispatch the event
6157
- const doDispatch = (detail) => {
6158
- // Skip dispatch if result is undefined and dispatchOnUndefined is false
6159
- if (detail === undefined && options?.dispatchOnUndefined === false) {
6160
- return;
6161
- }
6162
- // Copy only platform EventInit fields; Snice-only decorator options do
6163
- // not belong in the CustomEvent constructor dictionary.
6164
- const event = new CustomEvent(eventName, {
6165
- bubbles: options?.bubbles ?? true,
6166
- cancelable: options?.cancelable ?? false,
6167
- composed: options?.composed ?? true,
6168
- detail
6169
- });
6170
- if (options?.scope !== undefined && options?.daemon !== undefined) {
6171
- throw new TypeError(`@dispatch('${eventName}') cannot use both scope and daemon.`);
6172
- }
6173
- if (options?.daemon !== undefined) {
6174
- requireDaemonTarget(this, options.daemon).dispatchEvent(event);
6175
- return;
6176
- }
6177
- if (options?.scope !== undefined) {
6178
- const target = resolveScope(this, options.scope);
6179
- if (!target) {
6180
- console.warn(`[snice/@dispatch] scope did not resolve for "${eventName}" — event not dispatched.`);
6181
- return;
6182
- }
6183
- target.dispatchEvent(event);
6184
- return;
6185
- }
6186
- const target = defaultCommunicationTarget(this);
6187
- if (!target) {
6188
- throw new TypeError(`@dispatch('${eventName}') requires an element, attached controller, or provided @daemon instance.`);
6189
- }
6190
- target.dispatchEvent(event);
6191
- };
6192
- // Helper to handle timed dispatch
6193
- const timedDispatch = (detail) => {
6194
- if (options?.debounce) {
6195
- clearTimeout(timers.debounceTimeout);
6196
- timers.debounceTimeout = setTimeout(() => doDispatch(detail), options.debounce);
6197
- return;
6198
- }
6199
- if (!options?.throttle) {
6200
- doDispatch(detail);
6201
- return;
6202
- }
6203
- const now = Date.now();
6204
- const remaining = options.throttle - (now - timers.throttleLastCall);
6205
- if (remaining <= 0) {
6206
- clearTimeout(timers.throttleTimeout);
6207
- timers.throttleLastCall = now;
6208
- doDispatch(detail);
6209
- return;
6210
- }
6211
- // Record the LATEST detail so the trailing dispatch carries fresh
6212
- // data, not the first-suppressed-call detail captured by closure.
6213
- timers.latestDetail = detail;
6214
- if (!timers.throttleTimeout) {
6215
- timers.throttleTimeout = setTimeout(() => {
6216
- timers.throttleLastCall = Date.now();
6217
- timers.throttleTimeout = null;
6218
- const d = timers.latestDetail;
6219
- timers.latestDetail = undefined;
6220
- doDispatch(d);
6221
- }, remaining);
6222
- }
6223
- };
6224
- // Handle async methods
6225
- if (result instanceof Promise) {
6226
- return result.then((resolvedResult) => {
6227
- timedDispatch(resolvedResult);
6228
- return resolvedResult;
6229
- });
6230
- }
6231
- // Sync method
6232
- timedDispatch(result);
6233
- return result;
6234
- };
6235
- };
6236
- }
6237
- /**
6238
- * Clear any pending debounce/throttle dispatch timers on an instance (e.g. on
6239
- * disconnect, so a queued event doesn't fire into a detached node). A dispatch
6240
- * is a one-shot signal, so pending ones are dropped, not replayed.
6241
- */
6242
- function clearDispatchTimers(instance) {
6243
- const timers = instance[DISPATCH_TIMERS];
6244
- if (!timers)
6245
- return;
6246
- for (const t of timers.values()) {
6247
- if (t.debounceTimeout)
6248
- clearTimeout(t.debounceTimeout);
6249
- if (t.throttleTimeout)
6250
- clearTimeout(t.throttleTimeout);
6251
- t.debounceTimeout = null;
6252
- t.throttleTimeout = null;
6253
- t.throttleLastCall = 0;
6254
- t.latestDetail = undefined;
6255
- }
6256
- }
6257
-
6258
6614
  /**
6259
6615
  * Method decorators for common patterns
6260
6616
  * @debounce, @throttle, @once, @memoize
@@ -7137,6 +7493,10 @@ function applyElementFunctionality(constructor) {
7137
7493
  scheduleAutofocus(this);
7138
7494
  };
7139
7495
  constructor.prototype.disconnectedCallback = async function () {
7496
+ // Invalidate @dispatch work queued or started before disconnect synchronously.
7497
+ // The platform does not await disconnectedCallback, and @dispose may be
7498
+ // async, so waiting until the end permits events from a detached host.
7499
+ clearDispatchTimers(this);
7140
7500
  try {
7141
7501
  disconnectRenderTree(this);
7142
7502
  }
@@ -7181,7 +7541,6 @@ function applyElementFunctionality(constructor) {
7181
7541
  if (clearRenderTimers(this)) {
7182
7542
  this[PENDING_RECONNECT_RENDER] = true;
7183
7543
  }
7184
- clearDispatchTimers(this);
7185
7544
  // @moved / @adopted debounce-throttle timers, likewise dropped so they
7186
7545
  // don't fire on a dead element.
7187
7546
  clearLifecycleTimers(this, MOVED_TIMERS);
@@ -7190,6 +7549,11 @@ function applyElementFunctionality(constructor) {
7190
7549
  constructor.prototype.attributeChangedCallback = function (name, oldValue, newValue) {
7191
7550
  originalAttributeChangedCallback?.call(this, name, oldValue, newValue);
7192
7551
  if (name === 'controller') {
7552
+ // Parsing a dynamic controller binding briefly creates this marker
7553
+ // attribute on the inert template element. It is renderer metadata,
7554
+ // not a registry name, so it must never start an attachment.
7555
+ if (hasTemplateMarker(newValue))
7556
+ return;
7193
7557
  // A class attachment reflects its decorator name for DOM diagnostics,
7194
7558
  // but the class reference remains the only attachment authority.
7195
7559
  if (this[CONTROLLER_ATTRIBUTE_SYNC])
@@ -7367,12 +7731,16 @@ function defineElement(tagName, constructor, context, options) {
7367
7731
  constructor.prototype[RENDER_OPTIONS] = constructor.renderOptions || {};
7368
7732
  }
7369
7733
  applyElementFunctionality(constructor);
7370
- if (customElements.get(tagName)) {
7734
+ const existing = customElements.get(tagName);
7735
+ if (existing) {
7736
+ if (existing === constructor)
7737
+ registerRenderHostIdentity(constructor, tagName);
7371
7738
  if (globalThis.SNICE_DEBUG)
7372
7739
  console.warn(`[snice] "${tagName}" is already registered. Skipping.`);
7373
7740
  return constructor;
7374
7741
  }
7375
7742
  customElements.define(tagName, constructor);
7743
+ registerRenderHostIdentity(constructor, tagName);
7376
7744
  return constructor;
7377
7745
  }
7378
7746
  function element(tagName, options) {
@@ -8781,6 +9149,72 @@ function provideRouterContext(root, navigation) {
8781
9149
  }, navigation);
8782
9150
  }
8783
9151
 
9152
+ const EMPTY_QUERY_MARKER = '__snice_empty_query_value__';
9153
+ const QUERY_PARAM_PATTERN = /=:[\w-]+\)?$/;
9154
+ function containsDecodedMarker(url, marker) {
9155
+ if (url.includes(marker))
9156
+ return true;
9157
+ const queryIndex = url.indexOf('?');
9158
+ if (queryIndex === -1)
9159
+ return false;
9160
+ return url.slice(queryIndex + 1).split('&').some(part => {
9161
+ try {
9162
+ return decodeURIComponent(part).includes(marker);
9163
+ }
9164
+ catch {
9165
+ return false;
9166
+ }
9167
+ });
9168
+ }
9169
+ function emptyQueryMarker(route, url) {
9170
+ let marker = EMPTY_QUERY_MARKER;
9171
+ while (route.spec.includes(marker) || containsDecodedMarker(url, marker)) {
9172
+ marker += '_';
9173
+ }
9174
+ return marker;
9175
+ }
9176
+ function fillEmptyQueryParams(route, url, marker) {
9177
+ const urlQueryIndex = url.indexOf('?');
9178
+ const routeQueryIndex = route.spec.indexOf('?');
9179
+ if (urlQueryIndex === -1 || routeQueryIndex === -1)
9180
+ return null;
9181
+ const urlParts = url.slice(urlQueryIndex + 1).split('&');
9182
+ const routeParts = route.spec.slice(routeQueryIndex + 1).split('&');
9183
+ let changed = false;
9184
+ const normalizedParts = urlParts.map((part, index) => {
9185
+ if (part.endsWith('=') && QUERY_PARAM_PATTERN.test(routeParts[index] ?? '')) {
9186
+ changed = true;
9187
+ return `${part}${marker}`;
9188
+ }
9189
+ return part;
9190
+ });
9191
+ if (!changed)
9192
+ return null;
9193
+ return `${url.slice(0, urlQueryIndex + 1)}${normalizedParts.join('&')}`;
9194
+ }
9195
+ /**
9196
+ * Match through pica-route while preserving legal empty query parameter values.
9197
+ * pica-route 1.1.2 rejects an empty param and therefore discards the whole route.
9198
+ * Keep this compatibility shim in sync with packages/react/src/route-match.ts.
9199
+ */
9200
+ function matchRoute(route, url) {
9201
+ const directMatch = route.match(url);
9202
+ if (directMatch !== false)
9203
+ return directMatch;
9204
+ const marker = emptyQueryMarker(route, url);
9205
+ const normalizedUrl = fillEmptyQueryParams(route, url, marker);
9206
+ if (normalizedUrl === null)
9207
+ return false;
9208
+ const params = route.match(normalizedUrl);
9209
+ if (params === false)
9210
+ return false;
9211
+ for (const name of Object.keys(params)) {
9212
+ if (params[name] === marker)
9213
+ params[name] = '';
9214
+ }
9215
+ return params;
9216
+ }
9217
+
8784
9218
  // Router historically allows a later Router instance to take over the same
8785
9219
  // target. Track only Router-owned provider releases so that handoff remains
8786
9220
  // explicit without weakening duplicate protection for public provideContext().
@@ -8896,8 +9330,13 @@ function Router(options) {
8896
9330
  delete this[CONTEXT_HANDLER];
8897
9331
  };
8898
9332
  // Define the custom element (skip if already registered)
8899
- if (!win.customElements.get(pageOptions.tag)) {
9333
+ const existing = win.customElements.get(pageOptions.tag);
9334
+ if (!existing) {
8900
9335
  win.customElements.define(pageOptions.tag, constructor);
9336
+ registerRenderHostIdentity(constructor, pageOptions.tag);
9337
+ }
9338
+ else if (existing === constructor) {
9339
+ registerRenderHostIdentity(constructor, pageOptions.tag);
8901
9340
  }
8902
9341
  // Register the routes with guards, layout, and placard
8903
9342
  pageOptions.routes.forEach(route => {
@@ -9147,7 +9586,7 @@ function Router(options) {
9147
9586
  }
9148
9587
  async function resolveRoute(path, target, stale) {
9149
9588
  for (const route of routes) {
9150
- const params = route.route.match(path);
9589
+ const params = matchRoute(route.route, path);
9151
9590
  const isMatch = params !== false;
9152
9591
  if (!isMatch) {
9153
9592
  continue;