@@ -143,50 +143,70 @@ const PLACEHOLDER_BLOCK_ICON = (() => null) as unknown as BlockIcon
143143const MAX_COMPILED_ATTACHMENT_BYTES = 5 * 1024 * 1024
144144
145145/**
146- * Static component files, computed once and shared across all VFS instances.
147- * Built from the UNGATED registry universe (preview blocks included) so this
148- * process-global cache can never be poisoned by one viewer's gated projection;
149- * per-viewer gating is applied when the map is stamped into each fresh VFS
146+ * The process-global static-component cache: the ungated component-file universe
147+ * plus the path→owner lookups the stamp-time filter needs. Bundled into one
148+ * object (not three separate module-level variables) so that build and
149+ * {@link resetStaticComponents} are ATOMIC — there is never a window where the
150+ * file map is present but an owner map is stale, half-populated, or double-
151+ * populated. Built from the UNGATED registry universe (preview blocks included)
152+ * so it can never be poisoned by one viewer's gated projection; per-viewer
153+ * gating is applied downstream when the files are stamped into each fresh VFS
150154 * (see {@link isStaticFileHidden}).
151155 */
152- let staticComponentFiles : Map < string , string > | null = null
156+ interface StaticComponents {
157+ /** VFS path → serialized content — the ungated universe. */
158+ files : Map < string , string >
159+ /**
160+ * `components/integrations/**` path → owning block. Block and trigger schema
161+ * files carry their owning type as the path basename, but integration paths
162+ * use the version-stripped service name, so their owners need this lookup.
163+ */
164+ integrationPathOwners : Map < string , Pick < BlockConfig , 'type' | 'preview' > >
165+ /**
166+ * `components/triggers/{provider}/{id}.json` path → owning block(s), recorded
167+ * by inverting each block's `triggers.available`. External-trigger paths key
168+ * on the trigger id + provider (not a block type). A trigger reachable from
169+ * more than one block (e.g. a GA block and its preview successor) is hidden
170+ * only when EVERY owning block is hidden, so this holds an array.
171+ */
172+ triggerPathOwners : Map < string , Array < Pick < BlockConfig , 'type' | 'preview' > > >
173+ }
153174
154- /**
155- * Owning block for each `components/integrations/**` file, recorded at build
156- * time. Block/trigger schema files carry their owning type as the path
157- * basename, but integration paths use the version-stripped service name — so
158- * their owners need this lookup for the stamp-time visibility filter.
159- */
160- const integrationPathOwners = new Map < string , Pick < BlockConfig , 'type' | 'preview' > > ( )
175+ let staticComponents : StaticComponents | null = null
161176
162177/**
163- * Owning block(s) for each `components/triggers/{provider}/{id}.json` file,
164- * recorded at build time by inverting each block's `triggers.available`.
165- * External-trigger paths are keyed on the trigger id + provider (not a block
166- * type), so — like integration paths — they need this lookup for the stamp-time
167- * visibility filter. A trigger can be reachable from more than one block (e.g. a
168- * GA block and its preview successor), so this holds an array and the trigger is
169- * hidden only when EVERY owning block is hidden.
178+ * Clear the process-global static-component cache. The next
179+ * {@link getStaticComponents} rebuilds files AND owner maps together from the
180+ * current registries. Registries are compile-time static, so production never
181+ * needs this — it exists for tests and hot-reload, where a stale or (given the
182+ * non-idempotent owner-map inversion) double-populated cache would otherwise
183+ * survive a registry swap. Mirrors `resetExposedIntegrationToolsCache`.
170184 */
171- const triggerPathOwners = new Map < string , Array < Pick < BlockConfig , 'type' | 'preview' > > > ( )
185+ export function resetStaticComponents ( ) : void {
186+ staticComponents = null
187+ }
172188
173189/**
174190 * Per-request visibility filter for the shared static files: hides files whose
175191 * owning block is gated for this viewer (unrevealed preview blocks — the
176192 * default with no context — and kill-switched types). Non-registry paths
177193 * (loop/parallel, connectors, overviews) are always visible.
178194 */
179- function isStaticFileHidden ( path : string , vis : BlockVisibilityState | null ) : boolean {
195+ function isStaticFileHidden (
196+ path : string ,
197+ vis : BlockVisibilityState | null ,
198+ components : StaticComponents
199+ ) : boolean {
180200 const blockMatch = path . match ( / ^ c o m p o n e n t s \/ (?: b l o c k s | t r i g g e r s \/ s i m ) \/ ( [ ^ / ] + ) \. j s o n $ / )
181201 if ( blockMatch ) {
182202 const config = BLOCK_REGISTRY [ blockMatch [ 1 ] ! ]
183203 return config ? isHiddenUnder ( vis , config ) : false
184204 }
185- const triggerOwners = triggerPathOwners . get ( path )
205+ const triggerOwners = components . triggerPathOwners . get ( path )
186206 if ( triggerOwners ) {
187207 return triggerOwners . length > 0 && triggerOwners . every ( ( owner ) => isHiddenUnder ( vis , owner ) )
188208 }
189- const owner = integrationPathOwners . get ( path )
209+ const owner = components . integrationPathOwners . get ( path )
190210 return owner ? isHiddenUnder ( vis , owner ) : false
191211}
192212
@@ -207,18 +227,21 @@ function isBinaryDocBuffer(buffer: Buffer, ext: string): boolean {
207227}
208228
209229/**
210- * Build the static component files from block and tool registries.
211- * This only needs to happen once per process.
230+ * Build the static component files + path→owner maps from the block and tool
231+ * registries, cached together as one {@link StaticComponents} object. Runs once
232+ * per process (until {@link resetStaticComponents}).
212233 *
213234 * Integration paths are derived deterministically from the block registry's
214235 * `tools.access` arrays rather than splitting tool IDs on underscores.
215236 * Each block declares which tools it owns, and the block type (minus version
216237 * suffix) becomes the service directory name.
217238 */
218- function getStaticComponentFiles ( ) : Map < string , string > {
219- if ( staticComponentFiles ) return staticComponentFiles
239+ function getStaticComponents ( ) : StaticComponents {
240+ if ( staticComponents ) return staticComponents
220241
221242 const files = new Map < string , string > ( )
243+ const integrationPathOwners = new Map < string , Pick < BlockConfig , 'type' | 'preview' > > ( )
244+ const triggerPathOwners = new Map < string , Array < Pick < BlockConfig , 'type' | 'preview' > > > ( )
222245
223246 // Raw registry, never the visibility-projected getAllBlocks: this map is a
224247 // process-global shared cache, so it must hold the deterministic ungated
@@ -366,8 +389,8 @@ function getStaticComponentFiles(): Map<string, string> {
366389 externalTriggers : externalTriggerCount ,
367390 } )
368391
369- staticComponentFiles = files
370- return staticComponentFiles
392+ staticComponents = { files, integrationPathOwners , triggerPathOwners }
393+ return staticComponents
371394}
372395
373396/**
@@ -384,13 +407,14 @@ function getStaticComponentFiles(): Map<string, string> {
384407 * Reuses the same predicates the stamp filter uses ({@link isHiddenUnder} for
385408 * builtin trigger blocks, {@link isStaticFileHidden} for external triggers,
386409 * {@link filterExposedIntegrationTools} for integration tools), so an overview
387- * entry appears exactly when its per-file schema does. Depends on the owner maps
388- * populated by {@link getStaticComponentFiles}, so it forces that build first
389- * (idempotent — normally already warm by materialize time) .
410+ * entry appears exactly when its per-file schema does. Takes the built
411+ * {@link StaticComponents} so its `triggerPathOwners` resolve external-trigger
412+ * visibility — the caller (materialize) already holds it .
390413 */
391- function buildViewerScopedComponentFiles ( vis : BlockVisibilityState | null ) : Map < string , string > {
392- getStaticComponentFiles ( )
393-
414+ function buildViewerScopedComponentFiles (
415+ vis : BlockVisibilityState | null ,
416+ components : StaticComponents
417+ ) : Map < string , string > {
394418 const files = new Map < string , string > ( )
395419
396420 const oauthServices = new Map < string , { provider : string ; operations : string [ ] } > ( )
@@ -434,7 +458,10 @@ function buildViewerScopedComponentFiles(vis: BlockVisibilityState | null): Map<
434458 . filter ( ( b ) => b . category === 'triggers' && ! isHiddenUnder ( vis , b ) )
435459 . map ( ( b ) => ( { id : b . type , name : b . name , provider : 'sim' , description : b . description } ) )
436460 const externalTriggers = Object . entries ( TRIGGER_REGISTRY )
437- . filter ( ( [ id , t ] ) => ! isStaticFileHidden ( `components/triggers/${ t . provider } /${ id } .json` , vis ) )
461+ . filter (
462+ ( [ id , t ] ) =>
463+ ! isStaticFileHidden ( `components/triggers/${ t . provider } /${ id } .json` , vis , components )
464+ )
438465 . map ( ( [ id , t ] ) => ( { id, name : t . name , provider : t . provider , description : t . description } ) )
439466 files . set (
440467 'components/triggers/triggers.md' ,
@@ -741,14 +768,18 @@ export class WorkspaceVFS {
741768 // Per-viewer gating happens HERE, not in the shared builder: files
742769 // owned by blocks hidden for this viewer are skipped at stamp time.
743770 const blockVisibility = overlayVisibility ( )
744- for ( const [ path , content ] of getStaticComponentFiles ( ) ) {
745- if ( isStaticFileHidden ( path , blockVisibility ) ) continue
771+ const staticComponents = getStaticComponents ( )
772+ for ( const [ path , content ] of staticComponents . files ) {
773+ if ( isStaticFileHidden ( path , blockVisibility , staticComponents ) ) continue
746774 this . files . set ( path , content )
747775 }
748776 // Overview files summarize a viewer-dependent set, so their CONTENT
749777 // (not just presence) is rebuilt per viewer — the stamp loop above
750778 // can only include/exclude whole files, not re-derive a list.
751- for ( const [ path , content ] of buildViewerScopedComponentFiles ( blockVisibility ) ) {
779+ for ( const [ path , content ] of buildViewerScopedComponentFiles (
780+ blockVisibility ,
781+ staticComponents
782+ ) ) {
752783 this . files . set ( path , content )
753784 }
754785
0 commit comments