@principal-ai/subsystems-react 0.53.4 → 0.53.6

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.
@@ -31,22 +31,50 @@ import { absoluteBoundsOf, computeElkLayout, flowPositionOf, pointAlongPath, poi
31
31
  import type { CompoundGroupDef, ElkLayoutResult } from '../utils/elkLayout';
32
32
  import { EDGE_LABEL_FONT_SIZE, EDGE_LABEL_HEIGHT, C4_LABEL_WIDTH, estimateEdgeLabelWidth } from '../utils/edgeLabel';
33
33
  import { deriveC4Groups, protocolColor, type C4Group } from './c4';
34
- import { C4NodeCard, NODE_H, NODE_W, OPEN_HEADER_PAD, nodeSize, nodeStyle } from './C4NodeCard';
34
+ import {
35
+ C4NodeCard,
36
+ NODE_H,
37
+ NODE_W,
38
+ OPEN_FOOTER_PAD,
39
+ OPEN_HEADER_PAD,
40
+ OPEN_SIDE_PAD,
41
+ nodeSize,
42
+ nodeStyle,
43
+ } from './C4NodeCard';
35
44
  import { TechMark, technologyBrand } from './techIcons';
36
45
  import type { TechBrand } from './techIcons';
37
46
  import { GRAPH_CANVAS_CLASS, GRAPH_NAV_PROPS, GraphChrome, GraphLayerStyle } from './graphChrome';
38
47
  import type { C4Element, C4Model } from './c4';
48
+ import { openContainerSet, openStateKey } from './c4';
39
49
 
40
50
  export interface C4GraphProps {
41
51
  model: C4Model;
42
52
  onSelectNode?: (id: string | null) => void;
43
53
  /**
44
- * Drill-down. Called from the expand affordance on a container card; the host
45
- * sets `openContainerId` on the model to swap the box for a frame with its
46
- * components inside.
54
+ * Drill-down: add `id` to `model.openContainerIds`, or remove it if it is
55
+ * already open. One verb for both directions, because with several containers
56
+ * open at once "close" is ambiguous — the affordance on an open card has to
57
+ * close *that* card without disturbing the others.
58
+ *
59
+ * This is the verb to use. The two below remain for callers written against
60
+ * the single-container drill-down, and are only consulted when this is absent.
61
+ */
62
+ onToggleContainer?: (id: string) => void;
63
+ /**
64
+ * Drill-down. Called from the expand affordance on a *closed* container card;
65
+ * the host adds `id` to `model.openContainerIds` to swap the box for a grown
66
+ * card with its components inside.
67
+ *
68
+ * @deprecated Prefer {@link C4GraphProps.onToggleContainer}, which also covers
69
+ * the open case.
47
70
  */
48
71
  onOpenContainer?: (id: string) => void;
49
- /** Called from the collapse affordance inside an opened container frame. */
72
+ /**
73
+ * Called from the collapse affordance on an opened container card. Closes
74
+ * everything when several containers are open, because it carries no id.
75
+ *
76
+ * @deprecated Prefer {@link C4GraphProps.onToggleContainer}, which closes one.
77
+ */
50
78
  onCloseContainer?: () => void;
51
79
  /**
52
80
  * Breathing room around each card, in flow px, applied as ELK spacing (not by
@@ -472,17 +500,32 @@ export function rebaseGroupBoundsAbsolute(
472
500
  * the frame is the origin its contents are laid out from — as a sibling, or as a
473
501
  * flat union, the frame has no stable corner and the whole boundary slides as
474
502
  * ELK re-places its members.
503
+ *
504
+ * Each opened container becomes its own ELK parent, so any number of them can be
505
+ * open at once. They are emitted in sorted order (the order `openContainerSet`
506
+ * gives) purely so the group list — and therefore the layout — is deterministic
507
+ * for a given set.
475
508
  */
476
509
  export function c4GroupDefs(
477
510
  model: C4Model,
478
- openId: string | null,
511
+ openIds?: readonly string[],
479
512
  ): { frames: C4Group[]; groups: CompoundGroupDef[] } {
513
+ // Normalized here rather than trusted from the caller. This function's output is
514
+ // cached under the open *set*, so a repeated id would emit the same group twice
515
+ // (ELK rejects a duplicate id) and a different order would produce a different
516
+ // group list for a diagram that did not change. An explicit argument wins over
517
+ // the model's own field, so a caller driving a state the model does not describe
518
+ // gets exactly that state.
519
+ const resolved = openContainerSet(
520
+ openIds !== undefined ? { openContainerIds: openIds } : model,
521
+ );
522
+ const opened = new Set(resolved);
480
523
  // Every frame this layout draws: the system boundary, plus a container frame per
481
524
  // container whose components are in view (the component view leans on these).
482
- // The *opened* container's frame is dropped — it is drawn as a grown card
525
+ // Each *opened* container's frame is dropped — it is drawn as a grown card
483
526
  // instead, as an ELK parent below.
484
- const frames = deriveC4Groups({ ...model, openContainerId: openId }).filter(
485
- (g) => g.id !== openId,
527
+ const frames = deriveC4Groups({ ...model, openContainerIds: resolved }).filter(
528
+ (g) => !opened.has(g.id),
486
529
  );
487
530
  const groups: CompoundGroupDef[] = frames.map((g) => ({
488
531
  id: g.id,
@@ -494,28 +537,35 @@ export function c4GroupDefs(
494
537
  // components — the "layout B" the closed layout transitions to. Only in the
495
538
  // container view: that is where a container is a card, and where the drill-down
496
539
  // is offered.
497
- const openMembers =
498
- model.view === 'container' && openId
499
- ? model.nodes
500
- .filter((n) => n.kind === 'component' && (n.container ?? n.parentId) === openId)
501
- .map((n) => n.id)
502
- : [];
503
- if (openId && openMembers.length > 0) {
504
- groups.push({
505
- id: openId,
506
- memberIds: openMembers,
507
- // INSIDE the system frame, not beside it. The boundary *is* the system, so a
508
- // sibling here would draw the opened container outside the very boundary it
509
- // belongs to.
510
- parentId: model.system.id,
511
- // Never narrower than the closed card, so opening only ever grows the box.
512
- minWidth: NODE_W,
513
- // Room for the container's own label slot + technology row.
514
- padTop: OPEN_HEADER_PAD,
515
- // Drawn as a card, laid out as a group — both are needed, and only ELK knows
516
- // which one a bare member id means.
517
- drawnAsCard: true,
518
- });
540
+ if (model.view === 'container') {
541
+ for (const openId of resolved) {
542
+ const openMembers = model.nodes
543
+ .filter((n) => n.kind === 'component' && (n.container ?? n.parentId) === openId)
544
+ .map((n) => n.id);
545
+ if (openMembers.length === 0) continue;
546
+ groups.push({
547
+ id: openId,
548
+ memberIds: openMembers,
549
+ // INSIDE the system frame, not beside it. The boundary *is* the system, so
550
+ // a sibling here would draw the opened container outside the very boundary
551
+ // it belongs to.
552
+ parentId: model.system.id,
553
+ // Never narrower than the closed card, so opening only ever grows the box.
554
+ minWidth: NODE_W,
555
+ // Room for the container's own label slot + technology row.
556
+ padTop: OPEN_HEADER_PAD,
557
+ // Room for the collapse affordance, which is absolutely positioned and so
558
+ // takes no space in the card's flow. Without it the fitted height stops at
559
+ // the last component row and that row is drawn through the button.
560
+ padBottom: OPEN_FOOTER_PAD,
561
+ // Breathing room at the sides, so the nested cards read as held inside the
562
+ // container rather than flush with the chrome above them.
563
+ padX: OPEN_SIDE_PAD,
564
+ // Drawn as a card, laid out as a group — both are needed, and only ELK
565
+ // knows which one a bare member id means.
566
+ drawnAsCard: true,
567
+ });
568
+ }
519
569
  }
520
570
 
521
571
  return { frames, groups };
@@ -571,7 +621,32 @@ function useFlip(from: FlowRect | undefined, to: FlowRect | undefined) {
571
621
  };
572
622
  }
573
623
 
574
- function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter = 28 }: C4GraphProps) {
624
+ /**
625
+ * Whether a nested component is withheld while the diagram is mid-morph.
626
+ *
627
+ * Only for a component whose *own* container is opening or closing right now.
628
+ * A container that was already open is not mid-grow — it is gliding to a new slot
629
+ * because ELK re-flowed the diagram around a different change, and React Flow
630
+ * carries its children along with it. Blanking its components anyway is what
631
+ * makes a multi-container drill-down read as a reset on every click, which is the
632
+ * opposite of the state it is meant to keep.
633
+ *
634
+ * `openIds` and `morphTargets` are sets because both are read once per node per
635
+ * render; a list would be a scan per node.
636
+ */
637
+ export function hidesComponentDuringMorph(
638
+ owner: string | undefined,
639
+ openIds: ReadonlySet<string>,
640
+ morphTargets: ReadonlySet<string>,
641
+ settled: boolean,
642
+ ): boolean {
643
+ // Settled: nothing is withheld. No owner: not nested under a container at all.
644
+ // Not open: its container is a closed card, so there is nothing to sit inside.
645
+ if (settled || !owner || !openIds.has(owner)) return false;
646
+ return morphTargets.has(owner);
647
+ }
648
+
649
+ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onCloseContainer, gutter = 28 }: C4GraphProps) {
575
650
  const { theme } = useTheme();
576
651
  const { fitView } = useReactFlow();
577
652
  const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
@@ -584,8 +659,33 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
584
659
  );
585
660
  /** Set by the layout effect; consumed by the `ready` effect to run the fit. */
586
661
  const pendingFitRef = useRef(false);
587
- /** Prefetched layouts, keyed by open-container id ('' = closed). */
662
+ /**
663
+ * Prefetched layouts, keyed by {@link openStateKey} of the open set ('' =
664
+ * closed).
665
+ *
666
+ * Capped because the key space grew: with one container open at a time there
667
+ * were exactly N+1 states, but a drill-down that keeps containers open reaches
668
+ * a different set on every click, and the prefetch fans out one step further
669
+ * from each. Map preserves insertion order, so the first key is the oldest —
670
+ * evicting it drops a layout that is furthest from the current state, which is
671
+ * the one least likely to be needed next.
672
+ */
588
673
  const layoutCache = useRef(new Map<string, Rendered>());
674
+ /** How many layouts to keep. Sized for a large diagram, not a typical one. */
675
+ const LAYOUT_CACHE_MAX = 64;
676
+ const cacheLayout = (key: string, layout: Rendered) => {
677
+ const cache = layoutCache.current;
678
+ // Re-insert so a cache hit counts as recent use and survives eviction.
679
+ cache.delete(key);
680
+ cache.set(key, layout);
681
+ for (const oldest of cache.keys()) {
682
+ if (cache.size <= LAYOUT_CACHE_MAX) break;
683
+ // Never evict what is on screen: a re-layout of the current state mid-morph
684
+ // would swap the geometry out from under the transition.
685
+ if (oldest === openStateKey(openIds)) continue;
686
+ cache.delete(oldest);
687
+ }
688
+ };
589
689
  /**
590
690
  * False while the drill-down grow/shrink is in flight. It gates the whole reveal:
591
691
  * the opened container's components stay hidden, every card drops its
@@ -604,17 +704,68 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
604
704
  const settleTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
605
705
  /** The post-morph re-frame; see `CAMERA_AFTER_MORPH_MS`. */
606
706
  const cameraTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
607
- /** The open id rendered last, so a change can morph between the two layouts. */
608
- const prevOpenRef = useRef<string | null>(null);
707
+ /**
708
+ * The open set rendered last, so a change can morph between the two layouts.
709
+ * The array, not just its key, because the morph needs to know *which*
710
+ * containers changed — see `morphTargetsRef`.
711
+ */
712
+ const prevOpenRef = useRef<string[]>([]);
713
+ /**
714
+ * Containers whose open state is changing *right now* — the symmetric
715
+ * difference of the previous and next open sets.
716
+ *
717
+ * This is what keeps an already-open container from blinking when a second one
718
+ * is opened. The grow is gated per container, not globally: ELK re-lays out the
719
+ * whole diagram, so every root card glides, but only the one being opened or
720
+ * closed has components that are genuinely arriving or leaving. Hiding every
721
+ * nested component for the length of the morph would make each open look like a
722
+ * wholesale reset of the drill-down, which is the opposite of the state the
723
+ * drill-down is supposed to be keeping.
724
+ */
725
+ const morphTargetsRef = useRef<Set<string>>(new Set());
609
726
 
610
727
  /**
611
- * Frame the whole system. Fit the SYSTEM node explicitly rather than the drawn
612
- * nodes: on a drill-down those are just the opened container and its components,
613
- * so fitting them would zoom to the container instead of the system.
728
+ * Frame the whole diagram: the system boundary *and* everything drawn outside
729
+ * it.
730
+ *
731
+ * Named explicitly rather than left to the drawn nodes, because on a drill-down
732
+ * those include the opened container's components — fitting all of them would
733
+ * zoom past the boundary into one container's contents. But naming only the
734
+ * boundary is the other half of the mistake: externals and people sit outside
735
+ * it by definition, so fitting the frame alone crops exactly the things the
736
+ * boundary is drawn in contrast to.
614
737
  */
615
738
  const fitSystem = useCallback(() => {
616
- fitView({ padding: 0.15, duration: CAMERA_MS, maxZoom: 1, nodes: [{ id: model.system.id }] });
617
- }, [fitView, model.system.id]);
739
+ const outside = model.nodes
740
+ .filter((n) => n.kind === 'external-system' || n.kind === 'person')
741
+ .map((n) => ({ id: n.id }));
742
+ fitView({
743
+ padding: 0.15,
744
+ duration: CAMERA_MS,
745
+ maxZoom: 1,
746
+ nodes: [{ id: model.system.id }, ...outside],
747
+ });
748
+ }, [fitView, model.system.id, model.nodes]);
749
+
750
+ /**
751
+ * The one drill-down verb. `onToggleContainer` says which container changed, so
752
+ * the host can add or remove exactly that one; the deprecated pair carries no
753
+ * id on the close side, so it can only close everything — correct for the
754
+ * single-container drill-down it was written for, and the reason it is not the
755
+ * preferred spelling.
756
+ */
757
+ const canToggle = !!(onToggleContainer || onOpenContainer);
758
+ const toggle = useCallback(
759
+ (id: string) => {
760
+ if (onToggleContainer) {
761
+ onToggleContainer(id);
762
+ return;
763
+ }
764
+ if (openContainerSet(model).includes(id)) onCloseContainer?.();
765
+ else onOpenContainer?.(id);
766
+ },
767
+ [onToggleContainer, onOpenContainer, onCloseContainer, model],
768
+ );
618
769
 
619
770
  // Fit the camera AFTER the new nodes are committed (when `ready` flips true),
620
771
  // so `fitView` measures the real geometry.
@@ -629,28 +780,36 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
629
780
  pendingFitRef.current = shouldRefitCamera(grows);
630
781
  };
631
782
 
783
+ // Normalized once and shared by everything downstream — the cache key, the
784
+ // membership tests, and the prefetch. Derived per model rather than inlined,
785
+ // because a permutation of the same set is the same diagram and must not read
786
+ // as a change.
787
+ const openIds = useMemo(() => openContainerSet(model), [model]);
788
+ const openKey = openStateKey(openIds);
789
+
632
790
  const layoutKey = useMemo(
633
791
  () =>
634
792
  [
635
793
  ...model.nodes.map((n) => `${n.id}\0${n.parentId ?? ''}\0${n.label}`).sort(),
636
794
  ...model.edges.map((e) => `${e.id}\0${e.count}`).sort(),
637
- `open:${model.openContainerId ?? ''}`,
795
+ `open:${openKey}`,
638
796
  ].join('\n'),
639
- [model],
797
+ [model, openKey],
640
798
  );
641
799
 
642
800
  /**
643
801
  * Build the React Flow inputs for a given open state — everything ELK needs,
644
- * independent of whether a layout has been run yet. Pure over `openId`, so it
802
+ * independent of whether a layout has been run yet. Pure over `openIds`, so it
645
803
  * serves both the rendered state and the background prefetch.
646
804
  */
647
- const buildInputs = (openId: string | null) => {
805
+ const buildInputs = (openIds: readonly string[]) => {
806
+ const opened = new Set(openIds);
648
807
  // Draw only the elements for this view. A container diagram shows the
649
808
  // runtime units (containers, externals, people); components belong to the
650
809
  // component view — or to a container that has been opened.
651
810
  const inView = (n: C4Element): boolean => {
652
811
  if (model.view === 'component') return n.kind !== 'container';
653
- if (n.kind === 'component') return (n.container ?? n.parentId) === openId;
812
+ if (n.kind === 'component') return opened.has(n.container ?? n.parentId ?? '');
654
813
  // A container is always drawn — it is a card either way. Opened, it grows
655
814
  // and nests its components; closed, it is the bare box.
656
815
  return true;
@@ -665,10 +824,14 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
665
824
 
666
825
  const rfNodes: Node[] = model.nodes.filter(inView).map((n, i) => {
667
826
  const size = nodeSize(n);
668
- // A component of the opened container nests under it (a React Flow child),
669
- // so edges between components can be drawn later. Everything else keeps its
670
- // authored parentId.
671
- const nested = n.kind === 'component' && openId ? openId : undefined;
827
+ // A component of an opened container nests under *its own* container (a
828
+ // React Flow child), so edges between components can be drawn later.
829
+ // Everything else keeps its authored parentId. The parent is this
830
+ // component's own container, not "the" open one — with several open at once
831
+ // there is no single open one, and nesting everything under the first would
832
+ // put one container's components inside the other.
833
+ const owner = n.kind === 'component' ? (n.container ?? n.parentId) : undefined;
834
+ const nested = owner && opened.has(owner) ? owner : undefined;
672
835
  const parentId = nested ?? n.parentId;
673
836
  return {
674
837
  id: n.id,
@@ -687,20 +850,14 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
687
850
  componentCount: childComponentCount.get(n.id) ?? 0,
688
851
  // A container with components opens; the same affordance closes it
689
852
  // again, so the toggle lives in one place.
690
- ...(n.kind === 'container' && (childComponentCount.get(n.id) ?? 0) > 0 && onOpenContainer
691
- ? {
692
- onExpand: () => {
693
- if (n.id === openId) onCloseContainer?.();
694
- else onOpenContainer?.(n.id);
695
- },
696
- open: n.id === openId,
697
- }
853
+ ...(n.kind === 'container' && (childComponentCount.get(n.id) ?? 0) > 0 && canToggle
854
+ ? { onExpand: () => toggle(n.id), open: opened.has(n.id) }
698
855
  : {}),
699
856
  },
700
857
  };
701
858
  });
702
859
 
703
- const { frames, groups } = c4GroupDefs(model, openId);
860
+ const { frames, groups } = c4GroupDefs(model, openIds);
704
861
 
705
862
  // Bands come from the *frames* only. The opened container is an ELK parent,
706
863
  // not a band — treating it as one would push it into its own layer, away from
@@ -740,10 +897,11 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
740
897
  * edges. The heavy step the prefetch caches.
741
898
  */
742
899
  const layoutFor = async (
743
- openId: string | null,
900
+ openIds: readonly string[],
744
901
  inputs: ReturnType<typeof buildInputs>,
745
902
  ): Promise<Rendered> => {
746
903
  const { rfNodes, rfEdges, groups, partitionByNode, frames } = inputs;
904
+ const opened = new Set(openIds);
747
905
  // The system boundary is a REAL ELK parent: its containers lay out *inside* it,
748
906
  // so ELK owns both the frame's origin and its size. That is what holds the
749
907
  // boundary's top-left corner steady between the two layouts, which is the whole
@@ -786,12 +944,12 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
786
944
  // reads it as absolute, so normalize it once at the boundary.
787
945
  const groupBounds = rebaseGroupBoundsAbsolute(result);
788
946
  const builtGroups = new Set(groupBounds.keys());
789
- // The opened container comes back from ELK as a parent, so it lands in
790
- // `groupBounds` — but it is drawn as a grown *card*, not a frame. Drop it
791
- // from the shells; keep it in `groupBounds` so nested children can take
792
- // their coordinates relative to it.
947
+ // The opened containers come back from ELK as parents, so they land in
948
+ // `groupBounds` — but each is drawn as a grown *card*, not a frame. Drop them
949
+ // all from the shells; keep them in `groupBounds` so their nested children can
950
+ // take their coordinates relative to them.
793
951
  const shellBounds = new Map(groupBounds);
794
- if (openId) shellBounds.delete(openId);
952
+ for (const openId of openIds) shellBounds.delete(openId);
795
953
 
796
954
  const groupById = new Map(frames.map((g) => [g.id, g]));
797
955
  const depthOf = (id: string): number => {
@@ -847,8 +1005,8 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
847
1005
  ...(def?.kind !== 'system' && element
848
1006
  ? { brand: technologyBrand('technology' in element ? element.technology : undefined) }
849
1007
  : {}),
850
- ...(def?.kind === 'container' && openId === id && onCloseContainer
851
- ? { onCollapse: () => onCloseContainer?.() }
1008
+ ...(def?.kind === 'container' && opened.has(id)
1009
+ ? { onCollapse: () => toggle(id) }
852
1010
  : {}),
853
1011
  },
854
1012
  };
@@ -858,14 +1016,13 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
858
1016
  let parentId: string | undefined;
859
1017
  if (n.data && typeof n.data === 'object') {
860
1018
  const modelNode = (n.data as { element?: C4Element }).element;
861
- // Only the opened container's own components nest under it — the sibling
862
- // containers and out-of-boundary nodes in the same layout must not.
863
- const nested =
864
- modelNode?.kind === 'component' &&
865
- openId &&
866
- (modelNode.container ?? modelNode.parentId) === openId
867
- ? openId
868
- : undefined;
1019
+ // A component nests under its own container when that container is open —
1020
+ // the sibling containers and out-of-boundary nodes in the same layout must
1021
+ // not. Reads the component's own owner rather than a single open id,
1022
+ // because several containers can be open at once.
1023
+ const owner =
1024
+ modelNode?.kind === 'component' ? (modelNode.container ?? modelNode.parentId) : undefined;
1025
+ const nested = owner && opened.has(owner) ? owner : undefined;
869
1026
  const candidate = nested ?? modelNode?.parentId;
870
1027
  if (candidate && builtGroups.has(candidate)) parentId = candidate;
871
1028
  }
@@ -875,9 +1032,9 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
875
1032
  flowPositionOf(result, n.id, parentId) ??
876
1033
  positioned.get(n.id)?.position ??
877
1034
  n.position;
878
- // The opened container is an ELK parent, so its box is whatever ELK fitted
1035
+ // An opened container is an ELK parent, so its box is whatever ELK fitted
879
1036
  // to the nested components — `nodeSize` only knows the closed card.
880
- const fitted = openId === n.id ? groupBounds.get(openId) : undefined;
1037
+ const fitted = opened.has(n.id) ? groupBounds.get(n.id) : undefined;
881
1038
  const node = {
882
1039
  ...n,
883
1040
  position: { x: abs.x, y: abs.y },
@@ -922,15 +1079,31 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
922
1079
  let alive = true;
923
1080
  setReady(false);
924
1081
  setSelectedId(null);
925
- const openId = model.openContainerId ?? null;
1082
+ const openKeyNow = openStateKey(openIds);
926
1083
  const prevOpen = prevOpenRef.current;
927
- const inputs = buildInputs(openId);
1084
+ const inputs = buildInputs(openIds);
928
1085
 
929
1086
  // Only a real open-state change animates; the first paint is already at rest.
930
- if (paintedRef.current && prevOpen !== openId) {
1087
+ if (paintedRef.current && openStateKey(prevOpen) !== openKeyNow) {
1088
+ // The containers in one set and not the other are the ones whose components
1089
+ // actually arrive or leave. Recorded as a ref rather than state: it is read
1090
+ // during the next render (to decide what to hide) but must not itself
1091
+ // schedule a render.
1092
+ const before = new Set(prevOpen);
1093
+ const after = new Set(openIds);
1094
+ morphTargetsRef.current = new Set([
1095
+ ...prevOpen.filter((id) => !after.has(id)),
1096
+ ...openIds.filter((id) => !before.has(id)),
1097
+ ]);
931
1098
  setSettled(false);
932
1099
  if (settleTimer.current) clearTimeout(settleTimer.current);
933
- settleTimer.current = setTimeout(() => setSettled(true), MORPH_MS);
1100
+ settleTimer.current = setTimeout(() => {
1101
+ setSettled(true);
1102
+ // The morph is over. Clearing the targets matters: an unrelated re-render
1103
+ // afterwards must not find a container still marked mid-morph and hide
1104
+ // components that are merely sitting still.
1105
+ morphTargetsRef.current = new Set();
1106
+ }, MORPH_MS);
934
1107
  // Re-frame once the grow has landed, not while it is running. The drill-down
935
1108
  // more than doubles the boundary, so the extent the camera was fitted to no
936
1109
  // longer contains the content — but moving *during* the morph compounds with
@@ -950,13 +1123,12 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
950
1123
  * Frames alone are not enough: ELK re-lays out the whole diagram, so every
951
1124
  * root card moves too. Without a card in this set it keeps its new slot the
952
1125
  * instant the layout swaps — a teleport, which is the most visible part of a
953
- * bad morph. Child nodes are excluded: they are hidden until the box settles,
954
- * so there is nothing to animate, and a transform on them would only fight
955
- * the parent's.
1126
+ * bad morph. Child nodes are excluded: a transform on them would only fight
1127
+ * the parent's, which carries the same movement.
956
1128
  */
957
1129
  const growsFor = (next: Rendered): Map<string, FrameGrow> | null => {
958
- if (prevOpen === openId) return null;
959
- const prevLayout = layoutCache.current.get(prevOpen ?? '');
1130
+ if (openStateKey(prevOpen) === openKeyNow) return null;
1131
+ const prevLayout = layoutCache.current.get(openStateKey(prevOpen));
960
1132
  if (!prevLayout) return null;
961
1133
  const grows = new Map<string, FrameGrow>();
962
1134
  const rootCardIds = (l: Rendered) =>
@@ -984,20 +1156,20 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
984
1156
  return grows.size > 0 ? grows : null;
985
1157
  };
986
1158
 
987
- const cached = layoutCache.current.get(openId ?? '');
1159
+ const cached = layoutCache.current.get(openKeyNow);
988
1160
  if (cached) {
989
- prevOpenRef.current = openId;
1161
+ prevOpenRef.current = openIds;
990
1162
  renderLayout(cached, growsFor(cached));
991
1163
  return () => {
992
1164
  alive = false;
993
1165
  };
994
1166
  }
995
- void layoutFor(openId, inputs)
1167
+ void layoutFor(openIds, inputs)
996
1168
  .then((r) => {
997
1169
  if (!alive) return;
998
- layoutCache.current.set(openId ?? '', r);
1170
+ cacheLayout(openKeyNow, r);
999
1171
  const span = growsFor(r);
1000
- prevOpenRef.current = openId;
1172
+ prevOpenRef.current = openIds;
1001
1173
  renderLayout(r, span);
1002
1174
  })
1003
1175
  .catch((err) => {
@@ -1031,18 +1203,31 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
1031
1203
  useEffect(() => {
1032
1204
  if (model.view !== 'container') return;
1033
1205
  let alive = true;
1034
- const containers = model.nodes.filter(
1206
+ const openable = model.nodes.filter(
1035
1207
  (n) => n.kind === 'container' && model.nodes.some((c) => c.kind === 'component' && (c.container ?? c.parentId) === n.id),
1036
1208
  );
1209
+ // One click ahead, and only one: opening `c` next means the current set plus
1210
+ // `c`. From the closed diagram that is just "open c", so the same rule covers
1211
+ // the first click on any container and every one after it — no separate path
1212
+ // for "nothing is open yet".
1213
+ //
1214
+ // Deliberately not the full power set. With N containers that would be 2^N
1215
+ // ELK runs and most of them unreachable; N-1 runs per visited state keeps the
1216
+ // next click instant, which is the only reason this exists. The cache is
1217
+ // capped below because the reachable states now grow with the states visited
1218
+ // rather than staying at N.
1219
+ const targets = openable
1220
+ .filter((c) => !openIds.includes(c.id))
1221
+ .map((c) => [...openIds, c.id].sort());
1037
1222
  void (async () => {
1038
- for (const c of containers) {
1223
+ for (const target of targets) {
1039
1224
  if (!alive) return;
1040
- if (layoutCache.current.has(c.id)) continue;
1225
+ const key = openStateKey(target);
1226
+ if (layoutCache.current.has(key)) continue;
1041
1227
  try {
1042
- const inputs = buildInputs(c.id);
1043
- const r = await layoutFor(c.id, inputs);
1228
+ const r = await layoutFor(target, buildInputs(target));
1044
1229
  if (!alive) return;
1045
- layoutCache.current.set(c.id, r);
1230
+ cacheLayout(key, r);
1046
1231
  } catch {
1047
1232
  // Prefetch is best-effort; a miss just falls back to compute-on-click.
1048
1233
  }
@@ -1077,28 +1262,33 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
1077
1262
  [model, selected],
1078
1263
  );
1079
1264
 
1080
- /** The container currently opened for drill-down, if any. */
1081
- const openContainerId = model.openContainerId ?? null;
1265
+ /** The containers open for drill-down, normalized. */
1266
+ const openContainerIds = useMemo(() => new Set(openIds), [openIds]);
1082
1267
 
1083
1268
  const displayNodes = useMemo(
1084
1269
  () =>
1085
1270
  nodes.map((n) => {
1086
1271
  if (n.type !== 'c4-node') return n;
1087
1272
  const element = (n.data as { element?: C4Element } | undefined)?.element;
1088
- // The nested components are already positioned inside the grown card,
1089
- // but they only *appear* once the grow settles — otherwise they are
1090
- // squeezed and clipped mid-animation.
1091
- const nested =
1092
- element?.kind === 'component' &&
1093
- openContainerId != null &&
1094
- (element.container ?? element.parentId) === openContainerId;
1273
+ const owner = element?.kind === 'component' ? (element.container ?? element.parentId) : undefined;
1274
+ // `hidesComponentDuringMorph` owns the rule; see there for why it is
1275
+ // per-container rather than global.
1276
+ const hidden = hidesComponentDuringMorph(
1277
+ owner,
1278
+ openContainerIds,
1279
+ morphTargetsRef.current,
1280
+ settled,
1281
+ );
1095
1282
  return {
1096
1283
  ...n,
1097
- ...(nested ? { hidden: !settled } : {}),
1284
+ ...(hidden ? { hidden: true } : {}),
1098
1285
  data: { ...n.data, selected: n.id === selectedId, settled },
1099
1286
  };
1100
1287
  }),
1101
- [nodes, selectedId, settled, openContainerId],
1288
+ // `morphTargetsRef` is a ref, so it cannot be a dependency; `settled` covers
1289
+ // it, because the two always change together — targets are written in the same
1290
+ // tick that clears `settled`, and cleared in the same tick that restores it.
1291
+ [nodes, selectedId, settled, openContainerIds],
1102
1292
  );
1103
1293
 
1104
1294
  /**