@principal-ai/subsystems-react 0.53.4 → 0.53.5

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.
@@ -18,9 +18,10 @@ import { Box, Minimize2 } from 'lucide-react';
18
18
  import { absoluteBoundsOf, computeElkLayout, flowPositionOf, pointAlongPath, pointsToSmoothPath } from '../utils/elkLayout';
19
19
  import { EDGE_LABEL_FONT_SIZE, EDGE_LABEL_HEIGHT, C4_LABEL_WIDTH, estimateEdgeLabelWidth } from '../utils/edgeLabel';
20
20
  import { deriveC4Groups, protocolColor } from './c4';
21
- import { C4NodeCard, NODE_H, NODE_W, OPEN_HEADER_PAD, nodeSize, nodeStyle } from './C4NodeCard';
21
+ import { C4NodeCard, NODE_H, NODE_W, OPEN_FOOTER_PAD, OPEN_HEADER_PAD, OPEN_SIDE_PAD, nodeSize, nodeStyle, } from './C4NodeCard';
22
22
  import { TechMark, technologyBrand } from './techIcons';
23
23
  import { GRAPH_CANVAS_CLASS, GRAPH_NAV_PROPS, GraphChrome, GraphLayerStyle } from './graphChrome';
24
+ import { openContainerSet, openStateKey } from './c4';
24
25
  function C4NodeView(props) {
25
26
  const { element, selected, componentCount, onExpand, open, settled, growFrom, frameRect } = props.data;
26
27
  const grown = open === true;
@@ -294,13 +295,26 @@ export function rebaseGroupBoundsAbsolute(result) {
294
295
  * the frame is the origin its contents are laid out from — as a sibling, or as a
295
296
  * flat union, the frame has no stable corner and the whole boundary slides as
296
297
  * ELK re-places its members.
298
+ *
299
+ * Each opened container becomes its own ELK parent, so any number of them can be
300
+ * open at once. They are emitted in sorted order (the order `openContainerSet`
301
+ * gives) purely so the group list — and therefore the layout — is deterministic
302
+ * for a given set.
297
303
  */
298
- export function c4GroupDefs(model, openId) {
304
+ export function c4GroupDefs(model, openIds) {
305
+ // Normalized here rather than trusted from the caller. This function's output is
306
+ // cached under the open *set*, so a repeated id would emit the same group twice
307
+ // (ELK rejects a duplicate id) and a different order would produce a different
308
+ // group list for a diagram that did not change. An explicit argument wins over
309
+ // the model's own field, so a caller driving a state the model does not describe
310
+ // gets exactly that state.
311
+ const resolved = openContainerSet(openIds !== undefined ? { openContainerIds: openIds } : model);
312
+ const opened = new Set(resolved);
299
313
  // Every frame this layout draws: the system boundary, plus a container frame per
300
314
  // container whose components are in view (the component view leans on these).
301
- // The *opened* container's frame is dropped — it is drawn as a grown card
315
+ // Each *opened* container's frame is dropped — it is drawn as a grown card
302
316
  // instead, as an ELK parent below.
303
- const frames = deriveC4Groups({ ...model, openContainerId: openId }).filter((g) => g.id !== openId);
317
+ const frames = deriveC4Groups({ ...model, openContainerIds: resolved }).filter((g) => !opened.has(g.id));
304
318
  const groups = frames.map((g) => ({
305
319
  id: g.id,
306
320
  memberIds: g.memberIds,
@@ -310,27 +324,36 @@ export function c4GroupDefs(model, openId) {
310
324
  // components — the "layout B" the closed layout transitions to. Only in the
311
325
  // container view: that is where a container is a card, and where the drill-down
312
326
  // is offered.
313
- const openMembers = model.view === 'container' && openId
314
- ? model.nodes
315
- .filter((n) => n.kind === 'component' && (n.container ?? n.parentId) === openId)
316
- .map((n) => n.id)
317
- : [];
318
- if (openId && openMembers.length > 0) {
319
- groups.push({
320
- id: openId,
321
- memberIds: openMembers,
322
- // INSIDE the system frame, not beside it. The boundary *is* the system, so a
323
- // sibling here would draw the opened container outside the very boundary it
324
- // belongs to.
325
- parentId: model.system.id,
326
- // Never narrower than the closed card, so opening only ever grows the box.
327
- minWidth: NODE_W,
328
- // Room for the container's own label slot + technology row.
329
- padTop: OPEN_HEADER_PAD,
330
- // Drawn as a card, laid out as a group — both are needed, and only ELK knows
331
- // which one a bare member id means.
332
- drawnAsCard: true,
333
- });
327
+ if (model.view === 'container') {
328
+ for (const openId of resolved) {
329
+ const openMembers = model.nodes
330
+ .filter((n) => n.kind === 'component' && (n.container ?? n.parentId) === openId)
331
+ .map((n) => n.id);
332
+ if (openMembers.length === 0)
333
+ continue;
334
+ groups.push({
335
+ id: openId,
336
+ memberIds: openMembers,
337
+ // INSIDE the system frame, not beside it. The boundary *is* the system, so
338
+ // a sibling here would draw the opened container outside the very boundary
339
+ // it belongs to.
340
+ parentId: model.system.id,
341
+ // Never narrower than the closed card, so opening only ever grows the box.
342
+ minWidth: NODE_W,
343
+ // Room for the container's own label slot + technology row.
344
+ padTop: OPEN_HEADER_PAD,
345
+ // Room for the collapse affordance, which is absolutely positioned and so
346
+ // takes no space in the card's flow. Without it the fitted height stops at
347
+ // the last component row and that row is drawn through the button.
348
+ padBottom: OPEN_FOOTER_PAD,
349
+ // Breathing room at the sides, so the nested cards read as held inside the
350
+ // container rather than flush with the chrome above them.
351
+ padX: OPEN_SIDE_PAD,
352
+ // Drawn as a card, laid out as a group — both are needed, and only ELK
353
+ // knows which one a bare member id means.
354
+ drawnAsCard: true,
355
+ });
356
+ }
334
357
  }
335
358
  return { frames, groups };
336
359
  }
@@ -386,7 +409,27 @@ function useFlip(from, to) {
386
409
  dy: from && to ? from.y - to.y : 0,
387
410
  };
388
411
  }
389
- function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter = 28 }) {
412
+ /**
413
+ * Whether a nested component is withheld while the diagram is mid-morph.
414
+ *
415
+ * Only for a component whose *own* container is opening or closing right now.
416
+ * A container that was already open is not mid-grow — it is gliding to a new slot
417
+ * because ELK re-flowed the diagram around a different change, and React Flow
418
+ * carries its children along with it. Blanking its components anyway is what
419
+ * makes a multi-container drill-down read as a reset on every click, which is the
420
+ * opposite of the state it is meant to keep.
421
+ *
422
+ * `openIds` and `morphTargets` are sets because both are read once per node per
423
+ * render; a list would be a scan per node.
424
+ */
425
+ export function hidesComponentDuringMorph(owner, openIds, morphTargets, settled) {
426
+ // Settled: nothing is withheld. No owner: not nested under a container at all.
427
+ // Not open: its container is a closed card, so there is nothing to sit inside.
428
+ if (settled || !owner || !openIds.has(owner))
429
+ return false;
430
+ return morphTargets.has(owner);
431
+ }
432
+ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onCloseContainer, gutter = 28 }) {
390
433
  const { theme } = useTheme();
391
434
  const { fitView } = useReactFlow();
392
435
  const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
@@ -397,8 +440,35 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
397
440
  const [labelPositions, setLabelPositions] = useState(() => new Map());
398
441
  /** Set by the layout effect; consumed by the `ready` effect to run the fit. */
399
442
  const pendingFitRef = useRef(false);
400
- /** Prefetched layouts, keyed by open-container id ('' = closed). */
443
+ /**
444
+ * Prefetched layouts, keyed by {@link openStateKey} of the open set ('' =
445
+ * closed).
446
+ *
447
+ * Capped because the key space grew: with one container open at a time there
448
+ * were exactly N+1 states, but a drill-down that keeps containers open reaches
449
+ * a different set on every click, and the prefetch fans out one step further
450
+ * from each. Map preserves insertion order, so the first key is the oldest —
451
+ * evicting it drops a layout that is furthest from the current state, which is
452
+ * the one least likely to be needed next.
453
+ */
401
454
  const layoutCache = useRef(new Map());
455
+ /** How many layouts to keep. Sized for a large diagram, not a typical one. */
456
+ const LAYOUT_CACHE_MAX = 64;
457
+ const cacheLayout = (key, layout) => {
458
+ const cache = layoutCache.current;
459
+ // Re-insert so a cache hit counts as recent use and survives eviction.
460
+ cache.delete(key);
461
+ cache.set(key, layout);
462
+ for (const oldest of cache.keys()) {
463
+ if (cache.size <= LAYOUT_CACHE_MAX)
464
+ break;
465
+ // Never evict what is on screen: a re-layout of the current state mid-morph
466
+ // would swap the geometry out from under the transition.
467
+ if (oldest === openStateKey(openIds))
468
+ continue;
469
+ cache.delete(oldest);
470
+ }
471
+ };
402
472
  /**
403
473
  * False while the drill-down grow/shrink is in flight. It gates the whole reveal:
404
474
  * the opened container's components stay hidden, every card drops its
@@ -417,16 +487,65 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
417
487
  const settleTimer = useRef(null);
418
488
  /** The post-morph re-frame; see `CAMERA_AFTER_MORPH_MS`. */
419
489
  const cameraTimer = useRef(null);
420
- /** The open id rendered last, so a change can morph between the two layouts. */
421
- const prevOpenRef = useRef(null);
422
490
  /**
423
- * Frame the whole system. Fit the SYSTEM node explicitly rather than the drawn
424
- * nodes: on a drill-down those are just the opened container and its components,
425
- * so fitting them would zoom to the container instead of the system.
491
+ * The open set rendered last, so a change can morph between the two layouts.
492
+ * The array, not just its key, because the morph needs to know *which*
493
+ * containers changed — see `morphTargetsRef`.
494
+ */
495
+ const prevOpenRef = useRef([]);
496
+ /**
497
+ * Containers whose open state is changing *right now* — the symmetric
498
+ * difference of the previous and next open sets.
499
+ *
500
+ * This is what keeps an already-open container from blinking when a second one
501
+ * is opened. The grow is gated per container, not globally: ELK re-lays out the
502
+ * whole diagram, so every root card glides, but only the one being opened or
503
+ * closed has components that are genuinely arriving or leaving. Hiding every
504
+ * nested component for the length of the morph would make each open look like a
505
+ * wholesale reset of the drill-down, which is the opposite of the state the
506
+ * drill-down is supposed to be keeping.
507
+ */
508
+ const morphTargetsRef = useRef(new Set());
509
+ /**
510
+ * Frame the whole diagram: the system boundary *and* everything drawn outside
511
+ * it.
512
+ *
513
+ * Named explicitly rather than left to the drawn nodes, because on a drill-down
514
+ * those include the opened container's components — fitting all of them would
515
+ * zoom past the boundary into one container's contents. But naming only the
516
+ * boundary is the other half of the mistake: externals and people sit outside
517
+ * it by definition, so fitting the frame alone crops exactly the things the
518
+ * boundary is drawn in contrast to.
426
519
  */
427
520
  const fitSystem = useCallback(() => {
428
- fitView({ padding: 0.15, duration: CAMERA_MS, maxZoom: 1, nodes: [{ id: model.system.id }] });
429
- }, [fitView, model.system.id]);
521
+ const outside = model.nodes
522
+ .filter((n) => n.kind === 'external-system' || n.kind === 'person')
523
+ .map((n) => ({ id: n.id }));
524
+ fitView({
525
+ padding: 0.15,
526
+ duration: CAMERA_MS,
527
+ maxZoom: 1,
528
+ nodes: [{ id: model.system.id }, ...outside],
529
+ });
530
+ }, [fitView, model.system.id, model.nodes]);
531
+ /**
532
+ * The one drill-down verb. `onToggleContainer` says which container changed, so
533
+ * the host can add or remove exactly that one; the deprecated pair carries no
534
+ * id on the close side, so it can only close everything — correct for the
535
+ * single-container drill-down it was written for, and the reason it is not the
536
+ * preferred spelling.
537
+ */
538
+ const canToggle = !!(onToggleContainer || onOpenContainer);
539
+ const toggle = useCallback((id) => {
540
+ if (onToggleContainer) {
541
+ onToggleContainer(id);
542
+ return;
543
+ }
544
+ if (openContainerSet(model).includes(id))
545
+ onCloseContainer?.();
546
+ else
547
+ onOpenContainer?.(id);
548
+ }, [onToggleContainer, onOpenContainer, onCloseContainer, model]);
430
549
  // Fit the camera AFTER the new nodes are committed (when `ready` flips true),
431
550
  // so `fitView` measures the real geometry.
432
551
  useEffect(() => {
@@ -439,17 +558,24 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
439
558
  const afterLayout = (grows) => {
440
559
  pendingFitRef.current = shouldRefitCamera(grows);
441
560
  };
561
+ // Normalized once and shared by everything downstream — the cache key, the
562
+ // membership tests, and the prefetch. Derived per model rather than inlined,
563
+ // because a permutation of the same set is the same diagram and must not read
564
+ // as a change.
565
+ const openIds = useMemo(() => openContainerSet(model), [model]);
566
+ const openKey = openStateKey(openIds);
442
567
  const layoutKey = useMemo(() => [
443
568
  ...model.nodes.map((n) => `${n.id}\0${n.parentId ?? ''}\0${n.label}`).sort(),
444
569
  ...model.edges.map((e) => `${e.id}\0${e.count}`).sort(),
445
- `open:${model.openContainerId ?? ''}`,
446
- ].join('\n'), [model]);
570
+ `open:${openKey}`,
571
+ ].join('\n'), [model, openKey]);
447
572
  /**
448
573
  * Build the React Flow inputs for a given open state — everything ELK needs,
449
- * independent of whether a layout has been run yet. Pure over `openId`, so it
574
+ * independent of whether a layout has been run yet. Pure over `openIds`, so it
450
575
  * serves both the rendered state and the background prefetch.
451
576
  */
452
- const buildInputs = (openId) => {
577
+ const buildInputs = (openIds) => {
578
+ const opened = new Set(openIds);
453
579
  // Draw only the elements for this view. A container diagram shows the
454
580
  // runtime units (containers, externals, people); components belong to the
455
581
  // component view — or to a container that has been opened.
@@ -457,7 +583,7 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
457
583
  if (model.view === 'component')
458
584
  return n.kind !== 'container';
459
585
  if (n.kind === 'component')
460
- return (n.container ?? n.parentId) === openId;
586
+ return opened.has(n.container ?? n.parentId ?? '');
461
587
  // A container is always drawn — it is a card either way. Opened, it grows
462
588
  // and nests its components; closed, it is the bare box.
463
589
  return true;
@@ -472,10 +598,14 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
472
598
  }
473
599
  const rfNodes = model.nodes.filter(inView).map((n, i) => {
474
600
  const size = nodeSize(n);
475
- // A component of the opened container nests under it (a React Flow child),
476
- // so edges between components can be drawn later. Everything else keeps its
477
- // authored parentId.
478
- const nested = n.kind === 'component' && openId ? openId : undefined;
601
+ // A component of an opened container nests under *its own* container (a
602
+ // React Flow child), so edges between components can be drawn later.
603
+ // Everything else keeps its authored parentId. The parent is this
604
+ // component's own container, not "the" open one — with several open at once
605
+ // there is no single open one, and nesting everything under the first would
606
+ // put one container's components inside the other.
607
+ const owner = n.kind === 'component' ? (n.container ?? n.parentId) : undefined;
608
+ const nested = owner && opened.has(owner) ? owner : undefined;
479
609
  const parentId = nested ?? n.parentId;
480
610
  return {
481
611
  id: n.id,
@@ -494,21 +624,13 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
494
624
  componentCount: childComponentCount.get(n.id) ?? 0,
495
625
  // A container with components opens; the same affordance closes it
496
626
  // again, so the toggle lives in one place.
497
- ...(n.kind === 'container' && (childComponentCount.get(n.id) ?? 0) > 0 && onOpenContainer
498
- ? {
499
- onExpand: () => {
500
- if (n.id === openId)
501
- onCloseContainer?.();
502
- else
503
- onOpenContainer?.(n.id);
504
- },
505
- open: n.id === openId,
506
- }
627
+ ...(n.kind === 'container' && (childComponentCount.get(n.id) ?? 0) > 0 && canToggle
628
+ ? { onExpand: () => toggle(n.id), open: opened.has(n.id) }
507
629
  : {}),
508
630
  },
509
631
  };
510
632
  });
511
- const { frames, groups } = c4GroupDefs(model, openId);
633
+ const { frames, groups } = c4GroupDefs(model, openIds);
512
634
  // Bands come from the *frames* only. The opened container is an ELK parent,
513
635
  // not a band — treating it as one would push it into its own layer, away from
514
636
  // the containers it belongs beside.
@@ -543,8 +665,9 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
543
665
  * Run ELK for one open state and turn the result into React Flow nodes /
544
666
  * edges. The heavy step the prefetch caches.
545
667
  */
546
- const layoutFor = async (openId, inputs) => {
668
+ const layoutFor = async (openIds, inputs) => {
547
669
  const { rfNodes, rfEdges, groups, partitionByNode, frames } = inputs;
670
+ const opened = new Set(openIds);
548
671
  // The system boundary is a REAL ELK parent: its containers lay out *inside* it,
549
672
  // so ELK owns both the frame's origin and its size. That is what holds the
550
673
  // boundary's top-left corner steady between the two layouts, which is the whole
@@ -586,12 +709,12 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
586
709
  // reads it as absolute, so normalize it once at the boundary.
587
710
  const groupBounds = rebaseGroupBoundsAbsolute(result);
588
711
  const builtGroups = new Set(groupBounds.keys());
589
- // The opened container comes back from ELK as a parent, so it lands in
590
- // `groupBounds` — but it is drawn as a grown *card*, not a frame. Drop it
591
- // from the shells; keep it in `groupBounds` so nested children can take
592
- // their coordinates relative to it.
712
+ // The opened containers come back from ELK as parents, so they land in
713
+ // `groupBounds` — but each is drawn as a grown *card*, not a frame. Drop them
714
+ // all from the shells; keep them in `groupBounds` so their nested children can
715
+ // take their coordinates relative to them.
593
716
  const shellBounds = new Map(groupBounds);
594
- if (openId)
717
+ for (const openId of openIds)
595
718
  shellBounds.delete(openId);
596
719
  const groupById = new Map(frames.map((g) => [g.id, g]));
597
720
  const depthOf = (id) => {
@@ -645,8 +768,8 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
645
768
  ...(def?.kind !== 'system' && element
646
769
  ? { brand: technologyBrand('technology' in element ? element.technology : undefined) }
647
770
  : {}),
648
- ...(def?.kind === 'container' && openId === id && onCloseContainer
649
- ? { onCollapse: () => onCloseContainer?.() }
771
+ ...(def?.kind === 'container' && opened.has(id)
772
+ ? { onCollapse: () => toggle(id) }
650
773
  : {}),
651
774
  },
652
775
  };
@@ -655,13 +778,12 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
655
778
  let parentId;
656
779
  if (n.data && typeof n.data === 'object') {
657
780
  const modelNode = n.data.element;
658
- // Only the opened container's own components nest under it — the sibling
659
- // containers and out-of-boundary nodes in the same layout must not.
660
- const nested = modelNode?.kind === 'component' &&
661
- openId &&
662
- (modelNode.container ?? modelNode.parentId) === openId
663
- ? openId
664
- : undefined;
781
+ // A component nests under its own container when that container is open —
782
+ // the sibling containers and out-of-boundary nodes in the same layout must
783
+ // not. Reads the component's own owner rather than a single open id,
784
+ // because several containers can be open at once.
785
+ const owner = modelNode?.kind === 'component' ? (modelNode.container ?? modelNode.parentId) : undefined;
786
+ const nested = owner && opened.has(owner) ? owner : undefined;
665
787
  const candidate = nested ?? modelNode?.parentId;
666
788
  if (candidate && builtGroups.has(candidate))
667
789
  parentId = candidate;
@@ -671,9 +793,9 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
671
793
  const abs = flowPositionOf(result, n.id, parentId) ??
672
794
  positioned.get(n.id)?.position ??
673
795
  n.position;
674
- // The opened container is an ELK parent, so its box is whatever ELK fitted
796
+ // An opened container is an ELK parent, so its box is whatever ELK fitted
675
797
  // to the nested components — `nodeSize` only knows the closed card.
676
- const fitted = openId === n.id ? groupBounds.get(openId) : undefined;
798
+ const fitted = opened.has(n.id) ? groupBounds.get(n.id) : undefined;
677
799
  const node = {
678
800
  ...n,
679
801
  position: { x: abs.x, y: abs.y },
@@ -716,15 +838,31 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
716
838
  let alive = true;
717
839
  setReady(false);
718
840
  setSelectedId(null);
719
- const openId = model.openContainerId ?? null;
841
+ const openKeyNow = openStateKey(openIds);
720
842
  const prevOpen = prevOpenRef.current;
721
- const inputs = buildInputs(openId);
843
+ const inputs = buildInputs(openIds);
722
844
  // Only a real open-state change animates; the first paint is already at rest.
723
- if (paintedRef.current && prevOpen !== openId) {
845
+ if (paintedRef.current && openStateKey(prevOpen) !== openKeyNow) {
846
+ // The containers in one set and not the other are the ones whose components
847
+ // actually arrive or leave. Recorded as a ref rather than state: it is read
848
+ // during the next render (to decide what to hide) but must not itself
849
+ // schedule a render.
850
+ const before = new Set(prevOpen);
851
+ const after = new Set(openIds);
852
+ morphTargetsRef.current = new Set([
853
+ ...prevOpen.filter((id) => !after.has(id)),
854
+ ...openIds.filter((id) => !before.has(id)),
855
+ ]);
724
856
  setSettled(false);
725
857
  if (settleTimer.current)
726
858
  clearTimeout(settleTimer.current);
727
- settleTimer.current = setTimeout(() => setSettled(true), MORPH_MS);
859
+ settleTimer.current = setTimeout(() => {
860
+ setSettled(true);
861
+ // The morph is over. Clearing the targets matters: an unrelated re-render
862
+ // afterwards must not find a container still marked mid-morph and hide
863
+ // components that are merely sitting still.
864
+ morphTargetsRef.current = new Set();
865
+ }, MORPH_MS);
728
866
  // Re-frame once the grow has landed, not while it is running. The drill-down
729
867
  // more than doubles the boundary, so the extent the camera was fitted to no
730
868
  // longer contains the content — but moving *during* the morph compounds with
@@ -744,14 +882,13 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
744
882
  * Frames alone are not enough: ELK re-lays out the whole diagram, so every
745
883
  * root card moves too. Without a card in this set it keeps its new slot the
746
884
  * instant the layout swaps — a teleport, which is the most visible part of a
747
- * bad morph. Child nodes are excluded: they are hidden until the box settles,
748
- * so there is nothing to animate, and a transform on them would only fight
749
- * the parent's.
885
+ * bad morph. Child nodes are excluded: a transform on them would only fight
886
+ * the parent's, which carries the same movement.
750
887
  */
751
888
  const growsFor = (next) => {
752
- if (prevOpen === openId)
889
+ if (openStateKey(prevOpen) === openKeyNow)
753
890
  return null;
754
- const prevLayout = layoutCache.current.get(prevOpen ?? '');
891
+ const prevLayout = layoutCache.current.get(openStateKey(prevOpen));
755
892
  if (!prevLayout)
756
893
  return null;
757
894
  const grows = new Map();
@@ -777,21 +914,21 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
777
914
  }
778
915
  return grows.size > 0 ? grows : null;
779
916
  };
780
- const cached = layoutCache.current.get(openId ?? '');
917
+ const cached = layoutCache.current.get(openKeyNow);
781
918
  if (cached) {
782
- prevOpenRef.current = openId;
919
+ prevOpenRef.current = openIds;
783
920
  renderLayout(cached, growsFor(cached));
784
921
  return () => {
785
922
  alive = false;
786
923
  };
787
924
  }
788
- void layoutFor(openId, inputs)
925
+ void layoutFor(openIds, inputs)
789
926
  .then((r) => {
790
927
  if (!alive)
791
928
  return;
792
- layoutCache.current.set(openId ?? '', r);
929
+ cacheLayout(openKeyNow, r);
793
930
  const span = growsFor(r);
794
- prevOpenRef.current = openId;
931
+ prevOpenRef.current = openIds;
795
932
  renderLayout(r, span);
796
933
  })
797
934
  .catch((err) => {
@@ -826,19 +963,32 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
826
963
  if (model.view !== 'container')
827
964
  return;
828
965
  let alive = true;
829
- const containers = model.nodes.filter((n) => n.kind === 'container' && model.nodes.some((c) => c.kind === 'component' && (c.container ?? c.parentId) === n.id));
966
+ const openable = model.nodes.filter((n) => n.kind === 'container' && model.nodes.some((c) => c.kind === 'component' && (c.container ?? c.parentId) === n.id));
967
+ // One click ahead, and only one: opening `c` next means the current set plus
968
+ // `c`. From the closed diagram that is just "open c", so the same rule covers
969
+ // the first click on any container and every one after it — no separate path
970
+ // for "nothing is open yet".
971
+ //
972
+ // Deliberately not the full power set. With N containers that would be 2^N
973
+ // ELK runs and most of them unreachable; N-1 runs per visited state keeps the
974
+ // next click instant, which is the only reason this exists. The cache is
975
+ // capped below because the reachable states now grow with the states visited
976
+ // rather than staying at N.
977
+ const targets = openable
978
+ .filter((c) => !openIds.includes(c.id))
979
+ .map((c) => [...openIds, c.id].sort());
830
980
  void (async () => {
831
- for (const c of containers) {
981
+ for (const target of targets) {
832
982
  if (!alive)
833
983
  return;
834
- if (layoutCache.current.has(c.id))
984
+ const key = openStateKey(target);
985
+ if (layoutCache.current.has(key))
835
986
  continue;
836
987
  try {
837
- const inputs = buildInputs(c.id);
838
- const r = await layoutFor(c.id, inputs);
988
+ const r = await layoutFor(target, buildInputs(target));
839
989
  if (!alive)
840
990
  return;
841
- layoutCache.current.set(c.id, r);
991
+ cacheLayout(key, r);
842
992
  }
843
993
  catch {
844
994
  // Prefetch is best-effort; a miss just falls back to compute-on-click.
@@ -861,24 +1011,26 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
861
1011
  ? model.nodes.filter((n) => n.kind === 'component' &&
862
1012
  (n.container === selected.id || n.parentId === selected.id))
863
1013
  : [], [model, selected]);
864
- /** The container currently opened for drill-down, if any. */
865
- const openContainerId = model.openContainerId ?? null;
1014
+ /** The containers open for drill-down, normalized. */
1015
+ const openContainerIds = useMemo(() => new Set(openIds), [openIds]);
866
1016
  const displayNodes = useMemo(() => nodes.map((n) => {
867
1017
  if (n.type !== 'c4-node')
868
1018
  return n;
869
1019
  const element = n.data?.element;
870
- // The nested components are already positioned inside the grown card,
871
- // but they only *appear* once the grow settles — otherwise they are
872
- // squeezed and clipped mid-animation.
873
- const nested = element?.kind === 'component' &&
874
- openContainerId != null &&
875
- (element.container ?? element.parentId) === openContainerId;
1020
+ const owner = element?.kind === 'component' ? (element.container ?? element.parentId) : undefined;
1021
+ // `hidesComponentDuringMorph` owns the rule; see there for why it is
1022
+ // per-container rather than global.
1023
+ const hidden = hidesComponentDuringMorph(owner, openContainerIds, morphTargetsRef.current, settled);
876
1024
  return {
877
1025
  ...n,
878
- ...(nested ? { hidden: !settled } : {}),
1026
+ ...(hidden ? { hidden: true } : {}),
879
1027
  data: { ...n.data, selected: n.id === selectedId, settled },
880
1028
  };
881
- }), [nodes, selectedId, settled, openContainerId]);
1029
+ }),
1030
+ // `morphTargetsRef` is a ref, so it cannot be a dependency; `settled` covers
1031
+ // it, because the two always change together — targets are written in the same
1032
+ // tick that clears `settled`, and cleared in the same tick that restores it.
1033
+ [nodes, selectedId, settled, openContainerIds]);
882
1034
  /**
883
1035
  * An edge's polyline is routed for the *settled* layout, so while the cards are
884
1036
  * gliding between the two slots the old lines would visibly point at the wrong