@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.
- package/.storybook/main.mts +1 -1
- package/.storybook/preview.mts +2 -2
- package/CHANGELOG.md +120 -0
- package/TASKS.md +11 -0
- package/dist/lib/chunk-SurfaceManager.mjs +960 -0
- package/dist/lib/chunk-SurfaceManager.mjs.map +1 -0
- package/dist/lib/chunk-capability.mjs +15 -0
- package/dist/lib/chunk-capability.mjs.map +1 -0
- package/dist/lib/{browser/chunk-FJ4765WW.mjs → chunk-context.mjs} +4 -5
- package/dist/lib/chunk-context.mjs.map +1 -0
- package/dist/lib/chunk-history-tracker.mjs +160 -0
- package/dist/lib/chunk-history-tracker.mjs.map +1 -0
- package/dist/lib/chunk-plugin-manager.mjs +1390 -0
- package/dist/lib/chunk-plugin-manager.mjs.map +1 -0
- package/dist/lib/chunk-plugin-manifest.mjs +125 -0
- package/dist/lib/chunk-plugin-manifest.mjs.map +1 -0
- package/dist/lib/chunk-process-manager-capability.mjs +126 -0
- package/dist/lib/chunk-process-manager-capability.mjs.map +1 -0
- package/dist/lib/chunk-rolldown-runtime.mjs +13 -0
- package/dist/lib/chunk-url-loader.mjs +347 -0
- package/dist/lib/chunk-url-loader.mjs.map +1 -0
- package/dist/lib/cli.mjs +78 -0
- package/dist/lib/cli.mjs.map +1 -0
- package/dist/lib/common/activation-events.mjs +35 -0
- package/dist/lib/common/activation-events.mjs.map +1 -0
- package/dist/lib/common/capabilities.mjs +184 -0
- package/dist/lib/common/capabilities.mjs.map +1 -0
- package/dist/lib/config.mjs +2 -0
- package/dist/lib/core/activation-event.mjs +55 -0
- package/dist/lib/core/activation-event.mjs.map +1 -0
- package/dist/lib/core/capability.mjs +189 -0
- package/dist/lib/core/capability.mjs.map +1 -0
- package/dist/lib/core/plugin-manager.mjs +2 -0
- package/dist/lib/core/plugin.mjs +340 -0
- package/dist/lib/core/plugin.mjs.map +1 -0
- package/dist/lib/core/url-loader.mjs +2 -0
- package/dist/lib/index.mjs +134 -0
- package/dist/lib/index.mjs.map +1 -0
- package/dist/lib/testing/react.mjs +70 -0
- package/dist/lib/testing/react.mjs.map +1 -0
- package/dist/lib/testing.mjs +399 -0
- package/dist/lib/testing.mjs.map +1 -0
- package/dist/lib/ui.mjs +636 -0
- package/dist/lib/ui.mjs.map +1 -0
- package/dist/lib/vite-plugin.mjs +986 -0
- package/dist/lib/vite-plugin.mjs.map +1 -0
- package/dist/plugin/node-esm/index.mjs +4 -3
- package/dist/plugin/node-esm/index.mjs.map +3 -3
- package/dist/plugin/node-esm/meta.json +1 -1
- package/dist/types/src/common/Role.d.ts +31 -0
- package/dist/types/src/common/Role.d.ts.map +1 -0
- package/dist/types/src/common/Role.test.d.ts +2 -0
- package/dist/types/src/common/Role.test.d.ts.map +1 -0
- package/dist/types/src/common/capabilities.d.ts +11 -2
- package/dist/types/src/common/capabilities.d.ts.map +1 -1
- package/dist/types/src/common/index.d.ts +1 -0
- package/dist/types/src/common/index.d.ts.map +1 -1
- package/dist/types/src/core/capability-manager.d.ts +1 -1
- package/dist/types/src/core/capability-manager.d.ts.map +1 -1
- package/dist/types/src/core/capability.d.ts +38 -5
- package/dist/types/src/core/capability.d.ts.map +1 -1
- package/dist/types/src/core/plugin-manager.d.ts +1 -1
- package/dist/types/src/core/plugin-manager.d.ts.map +1 -1
- package/dist/types/src/core/plugin.d.ts +14 -1
- package/dist/types/src/core/plugin.d.ts.map +1 -1
- package/dist/types/src/core/registry.d.ts +1 -1
- package/dist/types/src/core/registry.d.ts.map +1 -1
- package/dist/types/src/plugin-process-manager/history/capability.d.ts +1 -1
- package/dist/types/src/plugin-process-manager/process-manager-capability.d.ts +1 -1
- package/dist/types/src/plugin-process-manager/process-manager-capability.d.ts.map +1 -1
- package/dist/types/src/testing/StorybookErrorFallback.d.ts +15 -0
- package/dist/types/src/testing/StorybookErrorFallback.d.ts.map +1 -0
- package/dist/types/src/testing/harness.d.ts +1 -1
- package/dist/types/src/testing/harness.d.ts.map +1 -1
- package/dist/types/src/testing/index.d.ts +2 -0
- package/dist/types/src/testing/index.d.ts.map +1 -1
- package/dist/types/src/testing/react.d.ts +4 -4
- package/dist/types/src/testing/react.d.ts.map +1 -1
- package/dist/types/src/testing/withPluginManager.d.ts.map +1 -1
- package/dist/types/src/testing/withPluginManager.stories.d.ts +6 -0
- package/dist/types/src/testing/withPluginManager.stories.d.ts.map +1 -1
- package/dist/types/src/testing/withSurfaceDebug.d.ts +10 -0
- package/dist/types/src/testing/withSurfaceDebug.d.ts.map +1 -0
- package/dist/types/src/ui/components/App/App.d.ts +0 -32
- package/dist/types/src/ui/components/App/App.d.ts.map +1 -1
- package/dist/types/src/ui/components/App/index.d.ts +1 -0
- package/dist/types/src/ui/components/App/index.d.ts.map +1 -1
- package/dist/types/src/ui/components/App/loader.d.ts +35 -0
- package/dist/types/src/ui/components/App/loader.d.ts.map +1 -0
- package/dist/types/src/ui/components/HomeSection/HomeSection.d.ts +26 -0
- package/dist/types/src/ui/components/HomeSection/HomeSection.d.ts.map +1 -0
- package/dist/types/src/ui/components/HomeSection/HomeSection.stories.d.ts +15 -0
- package/dist/types/src/ui/components/HomeSection/HomeSection.stories.d.ts.map +1 -0
- package/dist/types/src/ui/components/HomeSection/index.d.ts +2 -0
- package/dist/types/src/ui/components/HomeSection/index.d.ts.map +1 -0
- package/dist/types/src/ui/components/NamePopover/NamePopover.d.ts +14 -0
- package/dist/types/src/ui/components/NamePopover/NamePopover.d.ts.map +1 -0
- package/dist/types/src/ui/components/NamePopover/index.d.ts +2 -0
- package/dist/types/src/ui/components/NamePopover/index.d.ts.map +1 -0
- package/dist/types/src/ui/components/Surface/SurfaceComponent.d.ts +21 -9
- package/dist/types/src/ui/components/Surface/SurfaceComponent.d.ts.map +1 -1
- package/dist/types/src/ui/components/Surface/SurfaceComponent.stories.d.ts +4 -1
- package/dist/types/src/ui/components/Surface/SurfaceComponent.stories.d.ts.map +1 -1
- package/dist/types/src/ui/components/Surface/SurfaceComponent.test.d.ts +2 -0
- package/dist/types/src/ui/components/Surface/SurfaceComponent.test.d.ts.map +1 -0
- package/dist/types/src/ui/components/Surface/SurfaceDebug.d.ts +40 -0
- package/dist/types/src/ui/components/Surface/SurfaceDebug.d.ts.map +1 -0
- package/dist/types/src/ui/components/Surface/SurfaceManager.d.ts +23 -0
- package/dist/types/src/ui/components/Surface/SurfaceManager.d.ts.map +1 -0
- package/dist/types/src/ui/components/Surface/SurfaceManagerContext.d.ts +11 -0
- package/dist/types/src/ui/components/Surface/SurfaceManagerContext.d.ts.map +1 -0
- package/dist/types/src/ui/components/Surface/SurfaceMetrics.d.ts +72 -0
- package/dist/types/src/ui/components/Surface/SurfaceMetrics.d.ts.map +1 -0
- package/dist/types/src/ui/components/Surface/SurfaceProfilerContext.d.ts.map +1 -1
- package/dist/types/src/ui/components/Surface/index.d.ts +20 -25
- package/dist/types/src/ui/components/Surface/index.d.ts.map +1 -1
- package/dist/types/src/ui/components/Surface/types.d.ts +56 -52
- package/dist/types/src/ui/components/Surface/types.d.ts.map +1 -1
- package/dist/types/src/ui/components/index.d.ts +2 -0
- package/dist/types/src/ui/components/index.d.ts.map +1 -1
- package/dist/types/src/ui/hooks/useApp.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/moon.yml +25 -18
- package/package.json +58 -73
- package/src/common/Role.test.ts +41 -0
- package/src/common/Role.ts +54 -0
- package/src/common/capabilities.ts +17 -2
- package/src/common/index.ts +1 -0
- package/src/core/capability-manager.test.ts +1 -1
- package/src/core/capability-manager.ts +1 -1
- package/src/core/capability.ts +45 -6
- package/src/core/plugin-manager.test.ts +38 -3
- package/src/core/plugin-manager.ts +18 -1
- package/src/core/plugin.ts +16 -1
- package/src/core/registry.ts +1 -1
- package/src/plugin-process-manager/process-manager-capability.ts +25 -2
- package/src/testing/StorybookErrorFallback.tsx +40 -0
- package/src/testing/harness.ts +1 -1
- package/src/testing/index.ts +2 -0
- package/src/testing/react.test.tsx +2 -1
- package/src/testing/react.tsx +14 -8
- package/src/testing/withPluginManager.stories.tsx +16 -1
- package/src/testing/withPluginManager.tsx +13 -2
- package/src/testing/withSurfaceDebug.tsx +22 -0
- package/src/ui/components/App/App.tsx +7 -35
- package/src/ui/components/App/index.ts +1 -0
- package/src/ui/components/App/loader.ts +39 -0
- package/src/ui/components/HomeSection/HomeSection.stories.tsx +43 -0
- package/src/ui/components/HomeSection/HomeSection.tsx +69 -0
- package/src/ui/components/HomeSection/index.ts +5 -0
- package/src/ui/components/NamePopover/NamePopover.tsx +70 -0
- package/src/ui/components/NamePopover/index.ts +5 -0
- package/src/ui/components/Surface/DESIGN.md +136 -0
- package/src/ui/components/Surface/SurfaceComponent.stories.tsx +70 -41
- package/src/ui/components/Surface/SurfaceComponent.test.tsx +403 -0
- package/src/ui/components/Surface/SurfaceComponent.tsx +209 -189
- package/src/ui/components/Surface/SurfaceDebug.tsx +435 -0
- package/src/ui/components/Surface/SurfaceManager.ts +95 -0
- package/src/ui/components/Surface/SurfaceManagerContext.ts +23 -0
- package/src/ui/components/Surface/SurfaceMetrics.ts +202 -0
- package/src/ui/components/Surface/SurfaceProfilerContext.tsx +12 -2
- package/src/ui/components/Surface/index.ts +24 -22
- package/src/ui/components/Surface/types.test.ts +47 -54
- package/src/ui/components/Surface/types.ts +66 -91
- package/src/ui/components/index.ts +2 -0
- package/src/ui/hooks/useApp.tsx +7 -3
- package/src/vite-plugin/boot-loader/loader-app/boot-loader.css +1 -1
- package/tsconfig.json +3 -0
- package/vite.config.ts +26 -0
- package/dist/lib/browser/capability-S44TE2H7.mjs +0 -35
- package/dist/lib/browser/capability-S44TE2H7.mjs.map +0 -7
- package/dist/lib/browser/chunk-3NQLTQCP.mjs +0 -47
- package/dist/lib/browser/chunk-3NQLTQCP.mjs.map +0 -7
- package/dist/lib/browser/chunk-53HJHMS5.mjs +0 -7
- package/dist/lib/browser/chunk-53HJHMS5.mjs.map +0 -7
- package/dist/lib/browser/chunk-FJ4765WW.mjs.map +0 -7
- package/dist/lib/browser/chunk-J5LGTIGS.mjs +0 -10
- package/dist/lib/browser/chunk-J5LGTIGS.mjs.map +0 -7
- package/dist/lib/browser/chunk-KRJ4KK2W.mjs +0 -505
- package/dist/lib/browser/chunk-KRJ4KK2W.mjs.map +0 -7
- package/dist/lib/browser/chunk-LUH4Y5F2.mjs +0 -430
- package/dist/lib/browser/chunk-LUH4Y5F2.mjs.map +0 -7
- package/dist/lib/browser/chunk-NKVRSMFN.mjs +0 -95
- package/dist/lib/browser/chunk-NKVRSMFN.mjs.map +0 -7
- package/dist/lib/browser/chunk-OO53M5UK.mjs +0 -260
- package/dist/lib/browser/chunk-OO53M5UK.mjs.map +0 -7
- package/dist/lib/browser/chunk-OPTS3ZSN.mjs +0 -83
- package/dist/lib/browser/chunk-OPTS3ZSN.mjs.map +0 -7
- package/dist/lib/browser/chunk-Q2GLJTVV.mjs +0 -12
- package/dist/lib/browser/chunk-Q2GLJTVV.mjs.map +0 -7
- package/dist/lib/browser/chunk-SYXIYT6T.mjs +0 -143
- package/dist/lib/browser/chunk-SYXIYT6T.mjs.map +0 -7
- package/dist/lib/browser/chunk-U5MESPM5.mjs +0 -603
- package/dist/lib/browser/chunk-U5MESPM5.mjs.map +0 -7
- package/dist/lib/browser/chunk-UGYCLOXE.mjs +0 -1471
- package/dist/lib/browser/chunk-UGYCLOXE.mjs.map +0 -7
- package/dist/lib/browser/chunk-VF37YTXJ.mjs +0 -28
- package/dist/lib/browser/chunk-VF37YTXJ.mjs.map +0 -7
- package/dist/lib/browser/chunk-YUVQIOTB.mjs +0 -60
- package/dist/lib/browser/chunk-YUVQIOTB.mjs.map +0 -7
- package/dist/lib/browser/cli/index.mjs +0 -74
- package/dist/lib/browser/cli/index.mjs.map +0 -7
- package/dist/lib/browser/common/activation-events.mjs +0 -20
- package/dist/lib/browser/common/activation-events.mjs.map +0 -7
- package/dist/lib/browser/common/capabilities.mjs +0 -56
- package/dist/lib/browser/common/capabilities.mjs.map +0 -7
- package/dist/lib/browser/config/index.mjs +0 -8
- package/dist/lib/browser/config/index.mjs.map +0 -7
- package/dist/lib/browser/core/activation-event.mjs +0 -20
- package/dist/lib/browser/core/activation-event.mjs.map +0 -7
- package/dist/lib/browser/core/capability.mjs +0 -32
- package/dist/lib/browser/core/capability.mjs.map +0 -7
- package/dist/lib/browser/core/plugin-manager.mjs +0 -19
- package/dist/lib/browser/core/plugin-manager.mjs.map +0 -7
- package/dist/lib/browser/core/plugin.mjs +0 -51
- package/dist/lib/browser/core/plugin.mjs.map +0 -7
- package/dist/lib/browser/core/url-loader.mjs +0 -24
- package/dist/lib/browser/core/url-loader.mjs.map +0 -7
- package/dist/lib/browser/index.mjs +0 -98
- package/dist/lib/browser/index.mjs.map +0 -7
- package/dist/lib/browser/meta.json +0 -1
- package/dist/lib/browser/process-manager-capability-QK4BMKMN.mjs +0 -132
- package/dist/lib/browser/process-manager-capability-QK4BMKMN.mjs.map +0 -7
- package/dist/lib/browser/testing/index.mjs +0 -359
- package/dist/lib/browser/testing/index.mjs.map +0 -7
- package/dist/lib/browser/testing/react.mjs +0 -78
- package/dist/lib/browser/testing/react.mjs.map +0 -7
- package/dist/lib/browser/ui/index.mjs +0 -60
- package/dist/lib/browser/ui/index.mjs.map +0 -7
- package/dist/lib/node-esm/capability-3YRF77LV.mjs +0 -36
- package/dist/lib/node-esm/capability-3YRF77LV.mjs.map +0 -7
- package/dist/lib/node-esm/chunk-2UPUZXGU.mjs +0 -9
- package/dist/lib/node-esm/chunk-2UPUZXGU.mjs.map +0 -7
- package/dist/lib/node-esm/chunk-37Z53PXZ.mjs +0 -10
- package/dist/lib/node-esm/chunk-37Z53PXZ.mjs.map +0 -7
- package/dist/lib/node-esm/chunk-4SE7SX7N.mjs +0 -261
- package/dist/lib/node-esm/chunk-4SE7SX7N.mjs.map +0 -7
- package/dist/lib/node-esm/chunk-BMW6P6QA.mjs +0 -48
- package/dist/lib/node-esm/chunk-BMW6P6QA.mjs.map +0 -7
- package/dist/lib/node-esm/chunk-COHOWGGJ.mjs +0 -144
- package/dist/lib/node-esm/chunk-COHOWGGJ.mjs.map +0 -7
- package/dist/lib/node-esm/chunk-DRT75WGD.mjs +0 -604
- package/dist/lib/node-esm/chunk-DRT75WGD.mjs.map +0 -7
- package/dist/lib/node-esm/chunk-FKMVORZN.mjs +0 -431
- package/dist/lib/node-esm/chunk-FKMVORZN.mjs.map +0 -7
- package/dist/lib/node-esm/chunk-FPW45EZH.mjs +0 -14
- package/dist/lib/node-esm/chunk-FPW45EZH.mjs.map +0 -7
- package/dist/lib/node-esm/chunk-GLH4LGHZ.mjs +0 -84
- package/dist/lib/node-esm/chunk-GLH4LGHZ.mjs.map +0 -7
- package/dist/lib/node-esm/chunk-HSLMI22Q.mjs +0 -11
- package/dist/lib/node-esm/chunk-HSLMI22Q.mjs.map +0 -7
- package/dist/lib/node-esm/chunk-IL7O7IRX.mjs +0 -1472
- package/dist/lib/node-esm/chunk-IL7O7IRX.mjs.map +0 -7
- package/dist/lib/node-esm/chunk-ND2HETHD.mjs +0 -506
- package/dist/lib/node-esm/chunk-ND2HETHD.mjs.map +0 -7
- package/dist/lib/node-esm/chunk-TLKYZKPW.mjs +0 -29
- package/dist/lib/node-esm/chunk-TLKYZKPW.mjs.map +0 -7
- package/dist/lib/node-esm/chunk-VCPGX7WN.mjs +0 -62
- package/dist/lib/node-esm/chunk-VCPGX7WN.mjs.map +0 -7
- package/dist/lib/node-esm/chunk-XSZMX53L.mjs +0 -96
- package/dist/lib/node-esm/chunk-XSZMX53L.mjs.map +0 -7
- package/dist/lib/node-esm/cli/index.mjs +0 -75
- package/dist/lib/node-esm/cli/index.mjs.map +0 -7
- package/dist/lib/node-esm/common/activation-events.mjs +0 -21
- package/dist/lib/node-esm/common/activation-events.mjs.map +0 -7
- package/dist/lib/node-esm/common/capabilities.mjs +0 -57
- package/dist/lib/node-esm/common/capabilities.mjs.map +0 -7
- package/dist/lib/node-esm/config/index.mjs +0 -9
- package/dist/lib/node-esm/config/index.mjs.map +0 -7
- package/dist/lib/node-esm/core/activation-event.mjs +0 -21
- package/dist/lib/node-esm/core/activation-event.mjs.map +0 -7
- package/dist/lib/node-esm/core/capability.mjs +0 -33
- package/dist/lib/node-esm/core/capability.mjs.map +0 -7
- package/dist/lib/node-esm/core/plugin-manager.mjs +0 -20
- package/dist/lib/node-esm/core/plugin-manager.mjs.map +0 -7
- package/dist/lib/node-esm/core/plugin.mjs +0 -52
- package/dist/lib/node-esm/core/plugin.mjs.map +0 -7
- package/dist/lib/node-esm/core/url-loader.mjs +0 -25
- package/dist/lib/node-esm/core/url-loader.mjs.map +0 -7
- package/dist/lib/node-esm/index.mjs +0 -99
- package/dist/lib/node-esm/index.mjs.map +0 -7
- package/dist/lib/node-esm/meta.json +0 -1
- package/dist/lib/node-esm/process-manager-capability-5S7WZ2JR.mjs +0 -133
- package/dist/lib/node-esm/process-manager-capability-5S7WZ2JR.mjs.map +0 -7
- package/dist/lib/node-esm/testing/index.mjs +0 -360
- package/dist/lib/node-esm/testing/index.mjs.map +0 -7
- package/dist/lib/node-esm/testing/react.mjs +0 -79
- package/dist/lib/node-esm/testing/react.mjs.map +0 -7
- package/dist/lib/node-esm/ui/index.mjs +0 -61
- package/dist/lib/node-esm/ui/index.mjs.map +0 -7
- package/dist/types/src/ui/components/Surface/SurfaceInfo.d.ts +0 -11
- package/dist/types/src/ui/components/Surface/SurfaceInfo.d.ts.map +0 -1
- package/src/ui/components/Surface/SurfaceInfo.tsx +0 -106
- 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
|
-
/**
|
|
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
|
|
6
|
-
|
|
7
|
-
// TODO(wittjosiah): Cleanup to avoid re-naming.
|
|
5
|
+
import { Role } from '../../../common';
|
|
8
6
|
import { SurfaceContext } from './context';
|
|
9
|
-
import { 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
|
|
18
|
+
type Binding as SurfaceBindingType,
|
|
19
|
+
type ComponentProps as SurfaceComponentPropsType,
|
|
19
20
|
type Definition as SurfaceDefinition,
|
|
20
|
-
type
|
|
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
|
-
|
|
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
|
|
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
|
|
46
|
-
export
|
|
42
|
+
export const makeFilter = makeFilterFn;
|
|
43
|
+
export const isFilter = isFilterFn;
|
|
47
44
|
|
|
48
|
-
|
|
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
|
-
|
|
56
|
-
export
|
|
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
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
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('
|
|
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:
|
|
16
|
+
const filter: Filter<Record<string, any>> = {
|
|
38
17
|
bindings: [{ role: 'org.dxos.test.role.r', guard: () => true }],
|
|
39
18
|
};
|
|
40
|
-
expect(
|
|
41
|
-
expect(
|
|
42
|
-
expect(
|
|
43
|
-
expect(
|
|
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
|
|
49
|
-
const token =
|
|
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
|
|
58
|
-
const tokenA =
|
|
59
|
-
const tokenB =
|
|
60
|
-
const filter:
|
|
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 =
|
|
80
|
-
const filter:
|
|
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('
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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('
|
|
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
|
-
|
|
99
|
-
const
|
|
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(
|
|
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(
|
|
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(
|
|
109
|
+
isSurfaceAvailable({ type: sectionToken, data: { attendableId: 123, subject: 'x' } });
|
|
117
110
|
});
|
|
118
111
|
});
|