@dxos/app-framework 0.10.0 → 0.11.1

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 (294) hide show
  1. package/.storybook/main.mts +1 -1
  2. package/.storybook/preview.mts +2 -2
  3. package/CHANGELOG.md +120 -0
  4. package/TASKS.md +11 -0
  5. package/dist/lib/chunk-SurfaceManager.mjs +960 -0
  6. package/dist/lib/chunk-SurfaceManager.mjs.map +1 -0
  7. package/dist/lib/chunk-capability.mjs +15 -0
  8. package/dist/lib/chunk-capability.mjs.map +1 -0
  9. package/dist/lib/{browser/chunk-FJ4765WW.mjs → chunk-context.mjs} +4 -5
  10. package/dist/lib/chunk-context.mjs.map +1 -0
  11. package/dist/lib/chunk-history-tracker.mjs +160 -0
  12. package/dist/lib/chunk-history-tracker.mjs.map +1 -0
  13. package/dist/lib/chunk-plugin-manager.mjs +1390 -0
  14. package/dist/lib/chunk-plugin-manager.mjs.map +1 -0
  15. package/dist/lib/chunk-plugin-manifest.mjs +125 -0
  16. package/dist/lib/chunk-plugin-manifest.mjs.map +1 -0
  17. package/dist/lib/chunk-process-manager-capability.mjs +126 -0
  18. package/dist/lib/chunk-process-manager-capability.mjs.map +1 -0
  19. package/dist/lib/chunk-rolldown-runtime.mjs +13 -0
  20. package/dist/lib/chunk-url-loader.mjs +347 -0
  21. package/dist/lib/chunk-url-loader.mjs.map +1 -0
  22. package/dist/lib/cli.mjs +78 -0
  23. package/dist/lib/cli.mjs.map +1 -0
  24. package/dist/lib/common/activation-events.mjs +35 -0
  25. package/dist/lib/common/activation-events.mjs.map +1 -0
  26. package/dist/lib/common/capabilities.mjs +184 -0
  27. package/dist/lib/common/capabilities.mjs.map +1 -0
  28. package/dist/lib/config.mjs +2 -0
  29. package/dist/lib/core/activation-event.mjs +55 -0
  30. package/dist/lib/core/activation-event.mjs.map +1 -0
  31. package/dist/lib/core/capability.mjs +189 -0
  32. package/dist/lib/core/capability.mjs.map +1 -0
  33. package/dist/lib/core/plugin-manager.mjs +2 -0
  34. package/dist/lib/core/plugin.mjs +340 -0
  35. package/dist/lib/core/plugin.mjs.map +1 -0
  36. package/dist/lib/core/url-loader.mjs +2 -0
  37. package/dist/lib/index.mjs +134 -0
  38. package/dist/lib/index.mjs.map +1 -0
  39. package/dist/lib/testing/react.mjs +70 -0
  40. package/dist/lib/testing/react.mjs.map +1 -0
  41. package/dist/lib/testing.mjs +399 -0
  42. package/dist/lib/testing.mjs.map +1 -0
  43. package/dist/lib/ui.mjs +636 -0
  44. package/dist/lib/ui.mjs.map +1 -0
  45. package/dist/lib/vite-plugin.mjs +986 -0
  46. package/dist/lib/vite-plugin.mjs.map +1 -0
  47. package/dist/plugin/node-esm/index.mjs +4 -3
  48. package/dist/plugin/node-esm/index.mjs.map +3 -3
  49. package/dist/plugin/node-esm/meta.json +1 -1
  50. package/dist/types/src/common/Role.d.ts +31 -0
  51. package/dist/types/src/common/Role.d.ts.map +1 -0
  52. package/dist/types/src/common/Role.test.d.ts +2 -0
  53. package/dist/types/src/common/Role.test.d.ts.map +1 -0
  54. package/dist/types/src/common/capabilities.d.ts +11 -2
  55. package/dist/types/src/common/capabilities.d.ts.map +1 -1
  56. package/dist/types/src/common/index.d.ts +1 -0
  57. package/dist/types/src/common/index.d.ts.map +1 -1
  58. package/dist/types/src/core/capability-manager.d.ts +1 -1
  59. package/dist/types/src/core/capability-manager.d.ts.map +1 -1
  60. package/dist/types/src/core/capability.d.ts +38 -5
  61. package/dist/types/src/core/capability.d.ts.map +1 -1
  62. package/dist/types/src/core/plugin-manager.d.ts +1 -1
  63. package/dist/types/src/core/plugin-manager.d.ts.map +1 -1
  64. package/dist/types/src/core/plugin.d.ts +14 -1
  65. package/dist/types/src/core/plugin.d.ts.map +1 -1
  66. package/dist/types/src/core/registry.d.ts +1 -1
  67. package/dist/types/src/core/registry.d.ts.map +1 -1
  68. package/dist/types/src/plugin-process-manager/history/capability.d.ts +1 -1
  69. package/dist/types/src/plugin-process-manager/process-manager-capability.d.ts +1 -1
  70. package/dist/types/src/plugin-process-manager/process-manager-capability.d.ts.map +1 -1
  71. package/dist/types/src/testing/StorybookErrorFallback.d.ts +15 -0
  72. package/dist/types/src/testing/StorybookErrorFallback.d.ts.map +1 -0
  73. package/dist/types/src/testing/harness.d.ts +1 -1
  74. package/dist/types/src/testing/harness.d.ts.map +1 -1
  75. package/dist/types/src/testing/index.d.ts +2 -0
  76. package/dist/types/src/testing/index.d.ts.map +1 -1
  77. package/dist/types/src/testing/react.d.ts +4 -4
  78. package/dist/types/src/testing/react.d.ts.map +1 -1
  79. package/dist/types/src/testing/withPluginManager.d.ts.map +1 -1
  80. package/dist/types/src/testing/withPluginManager.stories.d.ts +6 -0
  81. package/dist/types/src/testing/withPluginManager.stories.d.ts.map +1 -1
  82. package/dist/types/src/testing/withSurfaceDebug.d.ts +10 -0
  83. package/dist/types/src/testing/withSurfaceDebug.d.ts.map +1 -0
  84. package/dist/types/src/ui/components/App/App.d.ts +0 -32
  85. package/dist/types/src/ui/components/App/App.d.ts.map +1 -1
  86. package/dist/types/src/ui/components/App/index.d.ts +1 -0
  87. package/dist/types/src/ui/components/App/index.d.ts.map +1 -1
  88. package/dist/types/src/ui/components/App/loader.d.ts +35 -0
  89. package/dist/types/src/ui/components/App/loader.d.ts.map +1 -0
  90. package/dist/types/src/ui/components/HomeSection/HomeSection.d.ts +26 -0
  91. package/dist/types/src/ui/components/HomeSection/HomeSection.d.ts.map +1 -0
  92. package/dist/types/src/ui/components/HomeSection/HomeSection.stories.d.ts +15 -0
  93. package/dist/types/src/ui/components/HomeSection/HomeSection.stories.d.ts.map +1 -0
  94. package/dist/types/src/ui/components/HomeSection/index.d.ts +2 -0
  95. package/dist/types/src/ui/components/HomeSection/index.d.ts.map +1 -0
  96. package/dist/types/src/ui/components/NamePopover/NamePopover.d.ts +14 -0
  97. package/dist/types/src/ui/components/NamePopover/NamePopover.d.ts.map +1 -0
  98. package/dist/types/src/ui/components/NamePopover/index.d.ts +2 -0
  99. package/dist/types/src/ui/components/NamePopover/index.d.ts.map +1 -0
  100. package/dist/types/src/ui/components/Surface/SurfaceComponent.d.ts +21 -9
  101. package/dist/types/src/ui/components/Surface/SurfaceComponent.d.ts.map +1 -1
  102. package/dist/types/src/ui/components/Surface/SurfaceComponent.stories.d.ts +4 -1
  103. package/dist/types/src/ui/components/Surface/SurfaceComponent.stories.d.ts.map +1 -1
  104. package/dist/types/src/ui/components/Surface/SurfaceComponent.test.d.ts +2 -0
  105. package/dist/types/src/ui/components/Surface/SurfaceComponent.test.d.ts.map +1 -0
  106. package/dist/types/src/ui/components/Surface/SurfaceDebug.d.ts +40 -0
  107. package/dist/types/src/ui/components/Surface/SurfaceDebug.d.ts.map +1 -0
  108. package/dist/types/src/ui/components/Surface/SurfaceManager.d.ts +23 -0
  109. package/dist/types/src/ui/components/Surface/SurfaceManager.d.ts.map +1 -0
  110. package/dist/types/src/ui/components/Surface/SurfaceManagerContext.d.ts +11 -0
  111. package/dist/types/src/ui/components/Surface/SurfaceManagerContext.d.ts.map +1 -0
  112. package/dist/types/src/ui/components/Surface/SurfaceMetrics.d.ts +72 -0
  113. package/dist/types/src/ui/components/Surface/SurfaceMetrics.d.ts.map +1 -0
  114. package/dist/types/src/ui/components/Surface/SurfaceProfilerContext.d.ts.map +1 -1
  115. package/dist/types/src/ui/components/Surface/index.d.ts +20 -25
  116. package/dist/types/src/ui/components/Surface/index.d.ts.map +1 -1
  117. package/dist/types/src/ui/components/Surface/types.d.ts +56 -52
  118. package/dist/types/src/ui/components/Surface/types.d.ts.map +1 -1
  119. package/dist/types/src/ui/components/index.d.ts +2 -0
  120. package/dist/types/src/ui/components/index.d.ts.map +1 -1
  121. package/dist/types/src/ui/hooks/useApp.d.ts.map +1 -1
  122. package/dist/types/tsconfig.tsbuildinfo +1 -1
  123. package/moon.yml +25 -18
  124. package/package.json +58 -73
  125. package/src/common/Role.test.ts +41 -0
  126. package/src/common/Role.ts +54 -0
  127. package/src/common/capabilities.ts +17 -2
  128. package/src/common/index.ts +1 -0
  129. package/src/core/capability-manager.test.ts +1 -1
  130. package/src/core/capability-manager.ts +1 -1
  131. package/src/core/capability.ts +45 -6
  132. package/src/core/plugin-manager.test.ts +38 -3
  133. package/src/core/plugin-manager.ts +18 -1
  134. package/src/core/plugin.ts +16 -1
  135. package/src/core/registry.ts +1 -1
  136. package/src/plugin-process-manager/process-manager-capability.ts +25 -2
  137. package/src/testing/StorybookErrorFallback.tsx +40 -0
  138. package/src/testing/harness.ts +1 -1
  139. package/src/testing/index.ts +2 -0
  140. package/src/testing/react.test.tsx +2 -1
  141. package/src/testing/react.tsx +14 -8
  142. package/src/testing/withPluginManager.stories.tsx +16 -1
  143. package/src/testing/withPluginManager.tsx +13 -2
  144. package/src/testing/withSurfaceDebug.tsx +22 -0
  145. package/src/ui/components/App/App.tsx +7 -35
  146. package/src/ui/components/App/index.ts +1 -0
  147. package/src/ui/components/App/loader.ts +39 -0
  148. package/src/ui/components/HomeSection/HomeSection.stories.tsx +43 -0
  149. package/src/ui/components/HomeSection/HomeSection.tsx +69 -0
  150. package/src/ui/components/HomeSection/index.ts +5 -0
  151. package/src/ui/components/NamePopover/NamePopover.tsx +70 -0
  152. package/src/ui/components/NamePopover/index.ts +5 -0
  153. package/src/ui/components/Surface/DESIGN.md +136 -0
  154. package/src/ui/components/Surface/SurfaceComponent.stories.tsx +70 -41
  155. package/src/ui/components/Surface/SurfaceComponent.test.tsx +403 -0
  156. package/src/ui/components/Surface/SurfaceComponent.tsx +209 -189
  157. package/src/ui/components/Surface/SurfaceDebug.tsx +435 -0
  158. package/src/ui/components/Surface/SurfaceManager.ts +95 -0
  159. package/src/ui/components/Surface/SurfaceManagerContext.ts +23 -0
  160. package/src/ui/components/Surface/SurfaceMetrics.ts +202 -0
  161. package/src/ui/components/Surface/SurfaceProfilerContext.tsx +12 -2
  162. package/src/ui/components/Surface/index.ts +24 -22
  163. package/src/ui/components/Surface/types.test.ts +47 -54
  164. package/src/ui/components/Surface/types.ts +66 -91
  165. package/src/ui/components/index.ts +2 -0
  166. package/src/ui/hooks/useApp.tsx +7 -3
  167. package/src/vite-plugin/boot-loader/loader-app/boot-loader.css +1 -1
  168. package/tsconfig.json +3 -0
  169. package/vite.config.ts +26 -0
  170. package/dist/lib/browser/capability-S44TE2H7.mjs +0 -35
  171. package/dist/lib/browser/capability-S44TE2H7.mjs.map +0 -7
  172. package/dist/lib/browser/chunk-3NQLTQCP.mjs +0 -47
  173. package/dist/lib/browser/chunk-3NQLTQCP.mjs.map +0 -7
  174. package/dist/lib/browser/chunk-53HJHMS5.mjs +0 -7
  175. package/dist/lib/browser/chunk-53HJHMS5.mjs.map +0 -7
  176. package/dist/lib/browser/chunk-FJ4765WW.mjs.map +0 -7
  177. package/dist/lib/browser/chunk-J5LGTIGS.mjs +0 -10
  178. package/dist/lib/browser/chunk-J5LGTIGS.mjs.map +0 -7
  179. package/dist/lib/browser/chunk-KRJ4KK2W.mjs +0 -505
  180. package/dist/lib/browser/chunk-KRJ4KK2W.mjs.map +0 -7
  181. package/dist/lib/browser/chunk-LUH4Y5F2.mjs +0 -430
  182. package/dist/lib/browser/chunk-LUH4Y5F2.mjs.map +0 -7
  183. package/dist/lib/browser/chunk-NKVRSMFN.mjs +0 -95
  184. package/dist/lib/browser/chunk-NKVRSMFN.mjs.map +0 -7
  185. package/dist/lib/browser/chunk-OO53M5UK.mjs +0 -260
  186. package/dist/lib/browser/chunk-OO53M5UK.mjs.map +0 -7
  187. package/dist/lib/browser/chunk-OPTS3ZSN.mjs +0 -83
  188. package/dist/lib/browser/chunk-OPTS3ZSN.mjs.map +0 -7
  189. package/dist/lib/browser/chunk-Q2GLJTVV.mjs +0 -12
  190. package/dist/lib/browser/chunk-Q2GLJTVV.mjs.map +0 -7
  191. package/dist/lib/browser/chunk-SYXIYT6T.mjs +0 -143
  192. package/dist/lib/browser/chunk-SYXIYT6T.mjs.map +0 -7
  193. package/dist/lib/browser/chunk-U5MESPM5.mjs +0 -603
  194. package/dist/lib/browser/chunk-U5MESPM5.mjs.map +0 -7
  195. package/dist/lib/browser/chunk-UGYCLOXE.mjs +0 -1471
  196. package/dist/lib/browser/chunk-UGYCLOXE.mjs.map +0 -7
  197. package/dist/lib/browser/chunk-VF37YTXJ.mjs +0 -28
  198. package/dist/lib/browser/chunk-VF37YTXJ.mjs.map +0 -7
  199. package/dist/lib/browser/chunk-YUVQIOTB.mjs +0 -60
  200. package/dist/lib/browser/chunk-YUVQIOTB.mjs.map +0 -7
  201. package/dist/lib/browser/cli/index.mjs +0 -74
  202. package/dist/lib/browser/cli/index.mjs.map +0 -7
  203. package/dist/lib/browser/common/activation-events.mjs +0 -20
  204. package/dist/lib/browser/common/activation-events.mjs.map +0 -7
  205. package/dist/lib/browser/common/capabilities.mjs +0 -56
  206. package/dist/lib/browser/common/capabilities.mjs.map +0 -7
  207. package/dist/lib/browser/config/index.mjs +0 -8
  208. package/dist/lib/browser/config/index.mjs.map +0 -7
  209. package/dist/lib/browser/core/activation-event.mjs +0 -20
  210. package/dist/lib/browser/core/activation-event.mjs.map +0 -7
  211. package/dist/lib/browser/core/capability.mjs +0 -32
  212. package/dist/lib/browser/core/capability.mjs.map +0 -7
  213. package/dist/lib/browser/core/plugin-manager.mjs +0 -19
  214. package/dist/lib/browser/core/plugin-manager.mjs.map +0 -7
  215. package/dist/lib/browser/core/plugin.mjs +0 -51
  216. package/dist/lib/browser/core/plugin.mjs.map +0 -7
  217. package/dist/lib/browser/core/url-loader.mjs +0 -24
  218. package/dist/lib/browser/core/url-loader.mjs.map +0 -7
  219. package/dist/lib/browser/index.mjs +0 -98
  220. package/dist/lib/browser/index.mjs.map +0 -7
  221. package/dist/lib/browser/meta.json +0 -1
  222. package/dist/lib/browser/process-manager-capability-QK4BMKMN.mjs +0 -132
  223. package/dist/lib/browser/process-manager-capability-QK4BMKMN.mjs.map +0 -7
  224. package/dist/lib/browser/testing/index.mjs +0 -359
  225. package/dist/lib/browser/testing/index.mjs.map +0 -7
  226. package/dist/lib/browser/testing/react.mjs +0 -78
  227. package/dist/lib/browser/testing/react.mjs.map +0 -7
  228. package/dist/lib/browser/ui/index.mjs +0 -60
  229. package/dist/lib/browser/ui/index.mjs.map +0 -7
  230. package/dist/lib/node-esm/capability-3YRF77LV.mjs +0 -36
  231. package/dist/lib/node-esm/capability-3YRF77LV.mjs.map +0 -7
  232. package/dist/lib/node-esm/chunk-2UPUZXGU.mjs +0 -9
  233. package/dist/lib/node-esm/chunk-2UPUZXGU.mjs.map +0 -7
  234. package/dist/lib/node-esm/chunk-37Z53PXZ.mjs +0 -10
  235. package/dist/lib/node-esm/chunk-37Z53PXZ.mjs.map +0 -7
  236. package/dist/lib/node-esm/chunk-4SE7SX7N.mjs +0 -261
  237. package/dist/lib/node-esm/chunk-4SE7SX7N.mjs.map +0 -7
  238. package/dist/lib/node-esm/chunk-BMW6P6QA.mjs +0 -48
  239. package/dist/lib/node-esm/chunk-BMW6P6QA.mjs.map +0 -7
  240. package/dist/lib/node-esm/chunk-COHOWGGJ.mjs +0 -144
  241. package/dist/lib/node-esm/chunk-COHOWGGJ.mjs.map +0 -7
  242. package/dist/lib/node-esm/chunk-DRT75WGD.mjs +0 -604
  243. package/dist/lib/node-esm/chunk-DRT75WGD.mjs.map +0 -7
  244. package/dist/lib/node-esm/chunk-FKMVORZN.mjs +0 -431
  245. package/dist/lib/node-esm/chunk-FKMVORZN.mjs.map +0 -7
  246. package/dist/lib/node-esm/chunk-FPW45EZH.mjs +0 -14
  247. package/dist/lib/node-esm/chunk-FPW45EZH.mjs.map +0 -7
  248. package/dist/lib/node-esm/chunk-GLH4LGHZ.mjs +0 -84
  249. package/dist/lib/node-esm/chunk-GLH4LGHZ.mjs.map +0 -7
  250. package/dist/lib/node-esm/chunk-HSLMI22Q.mjs +0 -11
  251. package/dist/lib/node-esm/chunk-HSLMI22Q.mjs.map +0 -7
  252. package/dist/lib/node-esm/chunk-IL7O7IRX.mjs +0 -1472
  253. package/dist/lib/node-esm/chunk-IL7O7IRX.mjs.map +0 -7
  254. package/dist/lib/node-esm/chunk-ND2HETHD.mjs +0 -506
  255. package/dist/lib/node-esm/chunk-ND2HETHD.mjs.map +0 -7
  256. package/dist/lib/node-esm/chunk-TLKYZKPW.mjs +0 -29
  257. package/dist/lib/node-esm/chunk-TLKYZKPW.mjs.map +0 -7
  258. package/dist/lib/node-esm/chunk-VCPGX7WN.mjs +0 -62
  259. package/dist/lib/node-esm/chunk-VCPGX7WN.mjs.map +0 -7
  260. package/dist/lib/node-esm/chunk-XSZMX53L.mjs +0 -96
  261. package/dist/lib/node-esm/chunk-XSZMX53L.mjs.map +0 -7
  262. package/dist/lib/node-esm/cli/index.mjs +0 -75
  263. package/dist/lib/node-esm/cli/index.mjs.map +0 -7
  264. package/dist/lib/node-esm/common/activation-events.mjs +0 -21
  265. package/dist/lib/node-esm/common/activation-events.mjs.map +0 -7
  266. package/dist/lib/node-esm/common/capabilities.mjs +0 -57
  267. package/dist/lib/node-esm/common/capabilities.mjs.map +0 -7
  268. package/dist/lib/node-esm/config/index.mjs +0 -9
  269. package/dist/lib/node-esm/config/index.mjs.map +0 -7
  270. package/dist/lib/node-esm/core/activation-event.mjs +0 -21
  271. package/dist/lib/node-esm/core/activation-event.mjs.map +0 -7
  272. package/dist/lib/node-esm/core/capability.mjs +0 -33
  273. package/dist/lib/node-esm/core/capability.mjs.map +0 -7
  274. package/dist/lib/node-esm/core/plugin-manager.mjs +0 -20
  275. package/dist/lib/node-esm/core/plugin-manager.mjs.map +0 -7
  276. package/dist/lib/node-esm/core/plugin.mjs +0 -52
  277. package/dist/lib/node-esm/core/plugin.mjs.map +0 -7
  278. package/dist/lib/node-esm/core/url-loader.mjs +0 -25
  279. package/dist/lib/node-esm/core/url-loader.mjs.map +0 -7
  280. package/dist/lib/node-esm/index.mjs +0 -99
  281. package/dist/lib/node-esm/index.mjs.map +0 -7
  282. package/dist/lib/node-esm/meta.json +0 -1
  283. package/dist/lib/node-esm/process-manager-capability-5S7WZ2JR.mjs +0 -133
  284. package/dist/lib/node-esm/process-manager-capability-5S7WZ2JR.mjs.map +0 -7
  285. package/dist/lib/node-esm/testing/index.mjs +0 -360
  286. package/dist/lib/node-esm/testing/index.mjs.map +0 -7
  287. package/dist/lib/node-esm/testing/react.mjs +0 -79
  288. package/dist/lib/node-esm/testing/react.mjs.map +0 -7
  289. package/dist/lib/node-esm/ui/index.mjs +0 -61
  290. package/dist/lib/node-esm/ui/index.mjs.map +0 -7
  291. package/dist/types/src/ui/components/Surface/SurfaceInfo.d.ts +0 -11
  292. package/dist/types/src/ui/components/Surface/SurfaceInfo.d.ts.map +0 -1
  293. package/src/ui/components/Surface/SurfaceInfo.tsx +0 -106
  294. package/vitest.config.ts +0 -14
@@ -0,0 +1,202 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { useSyncExternalStore } from 'react';
6
+
7
+ /**
8
+ * Per-surface dev metrics, complementing the render-timing data collected by the
9
+ * React Profiler ({@link SurfaceProfilerStats}). Captures dispatch-level signals
10
+ * the Profiler cannot see: how many candidates matched, whether the consumer's
11
+ * `data` prop is referentially unstable (the most common Surface footgun), error
12
+ * boundary trips, and mount churn.
13
+ *
14
+ * Keyed by the same `surface/<id>/<role>` identifier the Profiler uses, so the
15
+ * two can be joined in the devtools panel.
16
+ */
17
+ export type SurfaceMetric = {
18
+ /** `surface/<id>/<role>`. */
19
+ id: string;
20
+ /** Surface definition id. */
21
+ surfaceId: string;
22
+ /** Resolved role NSID. */
23
+ role: string;
24
+ /** Number of times the dispatcher resolved candidates for this surface. */
25
+ dispatches: number;
26
+ /** Candidates matched on the last dispatch. */
27
+ candidates: number;
28
+ /** `true` when more candidates matched than `limit` rendered. */
29
+ truncated: boolean;
30
+ /** `true` when the `data` prop identity churns across renders without changing value. */
31
+ dataUnstable: boolean;
32
+ /** Consecutive renders where `data` identity changed but value did not. */
33
+ dataChurn: number;
34
+ /** Error boundary trips. */
35
+ errors: number;
36
+ /** Mounts of the matched component. */
37
+ mounts: number;
38
+ /** Unmounts of the matched component. */
39
+ unmounts: number;
40
+ };
41
+
42
+ /** Consecutive unstable renders before `dataUnstable` is flagged. */
43
+ const UNSTABLE_THRESHOLD = 3;
44
+
45
+ export const surfaceMetricKey = (surfaceId: string, role: string): string => `surface/${surfaceId}/${role}`;
46
+
47
+ /**
48
+ * Shallow (top-level) value equality. Used to distinguish a genuinely new `data`
49
+ * value from a new object/array carrying the same content (the unstable-prop case).
50
+ */
51
+ const shallowEqual = (a: unknown, b: unknown): boolean => {
52
+ if (Object.is(a, b)) {
53
+ return true;
54
+ }
55
+ if (typeof a !== 'object' || a === null || typeof b !== 'object' || b === null) {
56
+ return false;
57
+ }
58
+ const aKeys = Object.keys(a as Record<string, unknown>);
59
+ const bKeys = Object.keys(b as Record<string, unknown>);
60
+ if (aKeys.length !== bKeys.length) {
61
+ return false;
62
+ }
63
+ return aKeys.every((key) => Object.is((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key]));
64
+ };
65
+
66
+ /**
67
+ * Singleton store of surface dev metrics. A module singleton (rather than a React
68
+ * context) so the debug overlay — which renders in its own root outside the app's
69
+ * provider tree — can read the same data as the in-app devtools panel.
70
+ */
71
+ class SurfaceMetricsStore {
72
+ #metrics = new Map<string, SurfaceMetric>();
73
+ #snapshot: SurfaceMetric[] = [];
74
+ #listeners = new Set<() => void>();
75
+ #pendingNotify = false;
76
+
77
+ #entry(surfaceId: string, role: string): SurfaceMetric {
78
+ const id = surfaceMetricKey(surfaceId, role);
79
+ let metric = this.#metrics.get(id);
80
+ if (!metric) {
81
+ metric = {
82
+ id,
83
+ surfaceId,
84
+ role,
85
+ dispatches: 0,
86
+ candidates: 0,
87
+ truncated: false,
88
+ dataUnstable: false,
89
+ dataChurn: 0,
90
+ errors: 0,
91
+ mounts: 0,
92
+ unmounts: 0,
93
+ };
94
+ this.#metrics.set(id, metric);
95
+ }
96
+ return metric;
97
+ }
98
+
99
+ /**
100
+ * Records a dispatch. `previousData` is the consumer's prior `data` reference;
101
+ * the store derives churn/instability from the identity-vs-value comparison.
102
+ */
103
+ recordDispatch(
104
+ surfaceId: string,
105
+ role: string,
106
+ args: { candidates: number; truncated: boolean; dataChurn: number },
107
+ ): void {
108
+ const metric = this.#entry(surfaceId, role);
109
+ metric.dispatches += 1;
110
+ metric.candidates = args.candidates;
111
+ metric.truncated = args.truncated;
112
+ metric.dataChurn = args.dataChurn;
113
+ metric.dataUnstable = args.dataChurn >= UNSTABLE_THRESHOLD;
114
+ this.#schedule();
115
+ }
116
+
117
+ recordError(surfaceId: string, role: string): void {
118
+ this.#entry(surfaceId, role).errors += 1;
119
+ this.#schedule();
120
+ }
121
+
122
+ recordMount(surfaceId: string, role: string): void {
123
+ this.#entry(surfaceId, role).mounts += 1;
124
+ this.#schedule();
125
+ }
126
+
127
+ recordUnmount(surfaceId: string, role: string): void {
128
+ this.#entry(surfaceId, role).unmounts += 1;
129
+ this.#schedule();
130
+ }
131
+
132
+ clear(): void {
133
+ this.#metrics.clear();
134
+ this.#snapshot = [];
135
+ this.#notify();
136
+ }
137
+
138
+ subscribe = (listener: () => void): (() => void) => {
139
+ this.#listeners.add(listener);
140
+ return () => {
141
+ this.#listeners.delete(listener);
142
+ };
143
+ };
144
+
145
+ getSnapshot = (): SurfaceMetric[] => this.#snapshot;
146
+
147
+ /** Defers notification to the next frame to coalesce bursts of records. */
148
+ #schedule(): void {
149
+ if (this.#pendingNotify || typeof requestAnimationFrame === 'undefined') {
150
+ if (typeof requestAnimationFrame === 'undefined') {
151
+ this.#notify();
152
+ }
153
+ return;
154
+ }
155
+ this.#pendingNotify = true;
156
+ requestAnimationFrame(() => {
157
+ this.#pendingNotify = false;
158
+ this.#notify();
159
+ });
160
+ }
161
+
162
+ #notify(): void {
163
+ this.#snapshot = [...this.#metrics.values()];
164
+ for (const listener of this.#listeners) {
165
+ listener();
166
+ }
167
+ }
168
+ }
169
+
170
+ export const surfaceMetrics = new SurfaceMetricsStore();
171
+
172
+ /**
173
+ * Updates the running churn count for a consumer's `data` prop.
174
+ *
175
+ * @returns the new churn count (0 when `data` changed value or is unchanged).
176
+ */
177
+ export const nextDataChurn = (previous: unknown, next: unknown, churn: number): number => {
178
+ // Unchanged reference: preserve churn. NOTE: do NOT reset to 0 here — `useDefaultValue`
179
+ // (used for the `data` default) lags one render, so each prop change yields two renders
180
+ // (stale-then-new); resetting on the stale (Object.is) render would wipe accumulated churn
181
+ // and never flag genuinely unstable data. The flag clears on the next genuine value change.
182
+ if (Object.is(previous, next)) {
183
+ return churn;
184
+ }
185
+ // New reference: churn only when the value is structurally the same (unstable identity).
186
+ return shallowEqual(previous, next) ? churn + 1 : 0;
187
+ };
188
+
189
+ /**
190
+ * Subscribes to surface dev metrics, sorted with the most concerning first
191
+ * (unstable data, then errors, then dispatch count).
192
+ */
193
+ export const useSurfaceMetrics = (): SurfaceMetric[] => {
194
+ const metrics = useSyncExternalStore(
195
+ surfaceMetrics.subscribe,
196
+ surfaceMetrics.getSnapshot,
197
+ surfaceMetrics.getSnapshot,
198
+ );
199
+ return [...metrics].sort(
200
+ (a, b) => Number(b.dataUnstable) - Number(a.dataUnstable) || b.errors - a.errors || b.dispatches - a.dispatches,
201
+ );
202
+ };
@@ -52,13 +52,22 @@ class SurfaceProfilerStore {
52
52
  private _snapshot: readonly SurfaceProfilerEntry[] = [];
53
53
  private _pendingNotify = false;
54
54
 
55
- /** Records an entry and schedules a deferred notification to avoid re-render loops. */
55
+ /**
56
+ * Records an entry and schedules a deferred notification to avoid re-render loops.
57
+ * `_snapshot` is rebuilt only when the deferred notification actually fires
58
+ * ({@link _notifySync}) — not here — so `getSnapshot` stays referentially stable for any
59
+ * synchronous re-invocation React makes within the same commit (e.g. the tearing check a
60
+ * profiled subscriber's own `useSyncExternalStore` runs right after this Profiler's
61
+ * `onRender` callback). Rebuilding it synchronously here would change what `getSnapshot`
62
+ * returns before listeners are told, which React reads as a torn store and forces an
63
+ * immediate re-render — and if the re-rendering component is itself profiled, that
64
+ * re-render re-triggers `record`, looping forever.
65
+ */
56
66
  record(entry: SurfaceProfilerEntry) {
57
67
  this._entries.push(entry);
58
68
  if (this._entries.length > MAX_ENTRIES) {
59
69
  this._entries = this._entries.slice(-MAX_ENTRIES);
60
70
  }
61
- this._snapshot = [...this._entries];
62
71
  this._scheduleNotify();
63
72
  }
64
73
 
@@ -94,6 +103,7 @@ class SurfaceProfilerStore {
94
103
  }
95
104
 
96
105
  private _notifySync() {
106
+ this._snapshot = [...this._entries];
97
107
  for (const listener of this._listeners) {
98
108
  listener();
99
109
  }
@@ -2,11 +2,11 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import type { DXN } from '@dxos/keys';
6
-
7
- // TODO(wittjosiah): Cleanup to avoid re-naming.
5
+ import { Role } from '../../../common';
8
6
  import { SurfaceContext } from './context';
9
- import { SurfaceComponent, isSurfaceAvailable } from './SurfaceComponent';
7
+ import { SurfaceComponent, useIsSurfaceAvailable } from './SurfaceComponent';
8
+ import { isSurfaceDebugEnabled, setSurfaceDebug } from './SurfaceDebug';
9
+ import { type SurfaceMetric, surfaceMetrics, useSurfaceMetrics } from './SurfaceMetrics';
10
10
  import {
11
11
  SurfaceProfilerProvider,
12
12
  useSurfaceProfilerCallback,
@@ -15,17 +15,15 @@ import {
15
15
  useSurfaceProfilerStats,
16
16
  } from './SurfaceProfilerContext';
17
17
  import {
18
- type SurfaceBinding as SurfaceBindingType,
18
+ type Binding as SurfaceBindingType,
19
+ type ComponentProps as SurfaceComponentPropsType,
19
20
  type Definition as SurfaceDefinition,
20
- type SurfaceFilter as SurfaceFilterType,
21
- type RoleToken as SurfaceRoleToken,
22
- type TokenData as SurfaceTokenData,
21
+ type Filter as SurfaceFilterType,
23
22
  type TypedProps as SurfaceTypedProps,
24
23
  create as createSurface,
25
24
  createWeb as createWebSurface,
26
- isSurfaceFilter as isSurfaceFilterFn,
25
+ isFilter as isFilterFn,
27
26
  makeFilter as makeFilterFn,
28
- makeType as makeTypeFn,
29
27
  } from './types';
30
28
 
31
29
  export namespace Surface {
@@ -37,29 +35,33 @@ export namespace Surface {
37
35
  export const Context = SurfaceContext;
38
36
 
39
37
  export const Surface = SurfaceComponent;
40
- export const isAvailable = isSurfaceAvailable;
38
+ export const useIsAvailable = useIsSurfaceAvailable;
41
39
 
42
- export type RoleToken<TData> = SurfaceRoleToken<TData>;
43
40
  export type Binding = SurfaceBindingType;
44
41
  export type Filter<TData> = SurfaceFilterType<TData>;
45
- export type TokenData<T> = SurfaceTokenData<T>;
46
- export type TypedProps<TToken extends SurfaceRoleToken<any>> = SurfaceTypedProps<TToken>;
42
+ export const makeFilter = makeFilterFn;
43
+ export const isFilter = isFilterFn;
47
44
 
48
- /** Mints a typed role token. The NSID is validated at compile time via {@link DXN.Name}. */
49
- export const makeType: {
50
- <TData = unknown, T extends string = string>(
51
- nsid: [DXN.Name<T>] extends [never] ? `Invalid NSID "${T}": final segment must be camelCase (no hyphens)` : T,
52
- ): SurfaceRoleToken<TData>;
53
- } = makeTypeFn;
45
+ export type TypedProps<TToken extends Role.Role<any>> = SurfaceTypedProps<TToken>;
54
46
 
55
- export const makeFilter = makeFilterFn;
56
- export const isFilter = isSurfaceFilterFn;
47
+ /** Props a matched surface component receives, and the input to a definition's `props` mapper. */
48
+ export type ComponentProps<T extends Record<string, any> = Record<string, any>> = SurfaceComponentPropsType<T>;
49
+
50
+ export const isDebugEnabled = isSurfaceDebugEnabled;
51
+ export const setDebug = setSurfaceDebug;
57
52
 
58
53
  export const ProfilerProvider = SurfaceProfilerProvider;
59
54
  export const useProfilerCallback = useSurfaceProfilerCallback;
60
55
  export const useProfilerEntries = useSurfaceProfilerEntries;
61
56
  export const useProfilerStats = useSurfaceProfilerStats;
62
57
  export const useProfilerClear = useSurfaceProfilerClear;
58
+
59
+ export type Metric = SurfaceMetric;
60
+ export const useMetrics = useSurfaceMetrics;
61
+ export const clearMetrics = () => surfaceMetrics.clear();
63
62
  }
64
63
 
65
64
  export type { SurfaceProfilerEntry, SurfaceProfilerStats } from './SurfaceProfilerContext';
65
+ export type { SurfaceMetric } from './SurfaceMetrics';
66
+ export { SurfaceManager } from './SurfaceManager';
67
+ export { SurfaceManagerProvider, useSurfaceManager } from './SurfaceManagerContext';
@@ -6,47 +6,26 @@ import { describe, test } from 'vitest';
6
6
 
7
7
  import { Position } from '@dxos/util';
8
8
 
9
- import { type CapabilityManager } from '../../../core';
10
- import { isSurfaceAvailable } from './SurfaceComponent';
11
- import { type RoleToken, type SurfaceFilter, create, isSurfaceFilter, makeFilter, makeType } from './types';
9
+ import * as Role from '../../../common/Role';
10
+ import { useIsSurfaceAvailable } from './SurfaceComponent';
11
+ import { indexByRole } from './SurfaceManager';
12
+ import { type Filter, create, isFilter, makeFilter } from './types';
12
13
 
13
- describe('Surface.makeType', () => {
14
- test('creates a role token with the given NSID', ({ expect }) => {
15
- const token = makeType<{ subject: string }>('org.dxos.test.role.testRole');
16
- expect(token.role).toBe('org.dxos.test.role.testRole');
17
- });
18
-
19
- test('tokens with the same role are independent objects (identity-by-role)', ({ expect }) => {
20
- const tokenOne = makeType<{ x: number }>('org.dxos.test.role.shared');
21
- const tokenTwo = makeType<{ x: number }>('org.dxos.test.role.shared');
22
- expect(tokenOne).not.toBe(tokenTwo);
23
- expect(tokenOne.role).toBe(tokenTwo.role);
24
- });
25
-
26
- test('throws on invalid NSID at runtime (hyphenated final segment)', ({ expect }) => {
27
- expect(() => makeType('org.dxos.test.role.bad-name' as any)).toThrow();
28
- });
29
-
30
- test('throws on single-segment NSID', ({ expect }) => {
31
- expect(() => makeType('article' as any)).toThrow();
32
- });
33
- });
34
-
35
- describe('isSurfaceFilter', () => {
14
+ describe('isFilter', () => {
36
15
  test('distinguishes filter objects from predicate functions', ({ expect }) => {
37
- const filter: SurfaceFilter<Record<string, any>> = {
16
+ const filter: Filter<Record<string, any>> = {
38
17
  bindings: [{ role: 'org.dxos.test.role.r', guard: () => true }],
39
18
  };
40
- expect(isSurfaceFilter(filter)).toBe(true);
41
- expect(isSurfaceFilter(() => true)).toBe(false);
42
- expect(isSurfaceFilter({})).toBe(false);
43
- expect(isSurfaceFilter(null)).toBe(false);
19
+ expect(isFilter(filter)).toBe(true);
20
+ expect(isFilter(() => true)).toBe(false);
21
+ expect(isFilter({})).toBe(false);
22
+ expect(isFilter(null)).toBe(false);
44
23
  });
45
24
  });
46
25
 
47
26
  describe('create', () => {
48
- test('expands a single-binding SurfaceFilter into a role string', ({ expect }) => {
49
- const token = makeType<Record<string, any>>('org.dxos.test.role.article');
27
+ test('expands a single-binding filter into a role string', ({ expect }) => {
28
+ const token = Role.make<Record<string, any>>('org.dxos.test.role.article');
50
29
  const filter = makeFilter(token, (data) => data.subject === 'ok');
51
30
  const def = create({ id: 'typedSingle', filter, component: () => null });
52
31
  expect(def.role).toBe('org.dxos.test.role.article');
@@ -54,10 +33,10 @@ describe('create', () => {
54
33
  expect(def.filter!({ subject: 'no' }, 'org.dxos.test.role.article')).toBe(false);
55
34
  });
56
35
 
57
- test('expands a multi-binding SurfaceFilter into a role array with role-scoped guards', ({ expect }) => {
58
- const tokenA = makeType<Record<string, any>>('org.dxos.test.role.article');
59
- const tokenB = makeType<Record<string, any>>('org.dxos.test.role.section');
60
- const filter: SurfaceFilter<Record<string, any>> = {
36
+ test('expands a multi-binding filter into a role array with role-scoped guards', ({ expect }) => {
37
+ const tokenA = Role.make<Record<string, any>>('org.dxos.test.role.article');
38
+ const tokenB = Role.make<Record<string, any>>('org.dxos.test.role.section');
39
+ const filter: Filter<Record<string, any>> = {
61
40
  bindings: [
62
41
  { role: tokenA.role, guard: (data) => (data as any).subject === 'a' },
63
42
  { role: tokenB.role, guard: (data) => (data as any).subject === 's' },
@@ -76,43 +55,57 @@ describe('create', () => {
76
55
  });
77
56
 
78
57
  test('passes position through untouched', ({ expect }) => {
79
- const token = makeType<Record<string, any>>('org.dxos.test.role.r');
80
- const filter: SurfaceFilter<Record<string, any>> = { bindings: [{ role: token.role, guard: () => true }] };
58
+ const token = Role.make<Record<string, any>>('org.dxos.test.role.r');
59
+ const filter: Filter<Record<string, any>> = { bindings: [{ role: token.role, guard: () => true }] };
81
60
  const def = create({ id: 'pos', filter, component: () => null, position: Position.last });
82
61
  expect(def.position).toBe(Position.last);
83
62
  });
63
+
64
+ test('does not throw on an invalid local id (dropped at dispatch instead)', ({ expect }) => {
65
+ const token = Role.make<Record<string, any>>('org.dxos.test.role.article');
66
+ const filter = makeFilter(token, () => true);
67
+ expect(() => create({ id: 'gallery-article', filter, component: () => null })).not.toThrow();
68
+ });
84
69
  });
85
70
 
86
- describe('role token typing', () => {
87
- test('makeType preserves TData through token use-sites', ({ expect }) => {
88
- // Type-level smoke test: if TS compiles, we're fine.
89
- const token: RoleToken<{ subject: number }> = makeType('org.dxos.test.role.numeric');
90
- expect(token.role).toBe('org.dxos.test.role.numeric');
71
+ describe('indexByRole', () => {
72
+ const token = Role.make<Record<string, any>>('org.dxos.test.role.article');
73
+ const filter = makeFilter(token, () => true);
74
+
75
+ test('indexes definitions by role', ({ expect }) => {
76
+ const valid = create({ id: 'valid', filter, component: () => null });
77
+ const index = indexByRole([valid]);
78
+ expect(index.get(token.role)).toEqual([valid]);
79
+ });
80
+
81
+ test('does not filter on id validity (SurfaceManager is responsible for that)', ({ expect }) => {
82
+ const valid = create({ id: 'valid', filter, component: () => null });
83
+ const invalid = create({ id: 'gallery-article', filter, component: () => null });
84
+ const index = indexByRole([valid, invalid]);
85
+ expect(index.get(token.role)).toEqual([valid, invalid]);
91
86
  });
92
87
  });
93
88
 
94
- describe('isSurfaceAvailable typing', () => {
89
+ describe('useIsSurfaceAvailable typing', () => {
95
90
  // These tests double as static assertions: the `@ts-expect-error` comments
96
91
  // fail to compile if the surrounding expression typechecks, so they verify
97
- // the typed overload narrows `data` to the token's declared contract.
98
- const sectionToken = makeType<{ attendableId: string; subject: string }>('org.dxos.test.role.section');
99
- const capabilityManager = { getAll: () => [] } as unknown as CapabilityManager.CapabilityManager;
92
+ // the typed overload narrows `data` to the token's declared contract. The
93
+ // hook is never invoked only its return type is used to type-check calls.
94
+ const sectionToken = Role.make<{ attendableId: string; subject: string }>('org.dxos.test.role.section');
95
+ const isSurfaceAvailable = (() => false) as ReturnType<typeof useIsSurfaceAvailable>;
100
96
 
101
97
  test('typed overload accepts data matching the token contract', () => {
102
98
  // No error — data has all required fields.
103
- isSurfaceAvailable(capabilityManager, {
104
- type: sectionToken,
105
- data: { attendableId: 'id', subject: 'x' },
106
- });
99
+ isSurfaceAvailable({ type: sectionToken, data: { attendableId: 'id', subject: 'x' } });
107
100
  });
108
101
 
109
102
  test('typed overload rejects data missing required fields', () => {
110
103
  // @ts-expect-error — `data` is missing `attendableId` required by the token.
111
- isSurfaceAvailable(capabilityManager, { type: sectionToken, data: { subject: 'x' } });
104
+ isSurfaceAvailable({ type: sectionToken, data: { subject: 'x' } });
112
105
  });
113
106
 
114
107
  test('typed overload rejects data with wrong field type', () => {
115
108
  // @ts-expect-error — `attendableId` must be a string, not a number.
116
- isSurfaceAvailable(capabilityManager, { type: sectionToken, data: { attendableId: 123, subject: 'x' } });
109
+ isSurfaceAvailable({ type: sectionToken, data: { attendableId: 123, subject: 'x' } });
117
110
  });
118
111
  });