Code component with a "diamond" dependency fails to hydrate: intermediate component is dropped from the scoped import map
_Claude Code was used to trace and analyse the bug, and write up the issue._ ## Problem/Motivation When a code component (JavaScript component) imports another code component that *also* shares a dependency with it, the shared component forms a "diamond" — `Pricing` imports both `Card` and `Button`, and `Card` also imports `Button` (one shared leaf): ```mermaid graph TD Pricing -->|"@/components/demo_card"| Card Pricing -->|"@/components/demo_button"| Button Card -->|"@/components/demo_button"| Button ``` In the browser the page fails to hydrate with: ``` [astro-island] Error hydrating /sites/default/files/astro-island/<hash>.js TypeError: Failed to resolve module specifier "@/components/demo_button". Relative references must start with either "/", "./", or "../". ``` The error names the top component's island (the hydration entry point), but the import that actually fails to resolve is inside the **intermediate** component (`Card`'s `import Button from "@/components/demo_button"`). ### Root cause `\Drupal\canvas\Plugin\Canvas\ComponentSource\JsComponent::getScopedDependencies()` builds the per-component scopes for the import map. It uses a `$seen` array to prevent infinite recursion on cyclic dependencies, but `$seen` is treated as a **global "already visited" set** that is carried across sibling branches. Because Drupal sorts config dependencies, the top component visits the shared leaf (`Button`, alphabetically first) before the intermediate (`Card`). By the time it recurses into `Card`, `Button` is already in `$seen`, so the loop `continue`s and **`Card`'s own scope entry for `Button` is never written**. Result: the emitted import map contains a scope for the top component but **no scope for the intermediate component**. When the browser loads the intermediate's module and tries to resolve `@/components/demo_button` from within it, there is no matching scope and no top-level `imports` fallback for `@/…`, so resolution throws. The bug only manifests when: 1. a component imports another component (an intermediate), AND 2. the importing component *also* directly imports a dependency of that intermediate (the shared leaf), AND 3. the shared leaf sorts before the intermediate among the top's dependencies (so it is visited first). ## Steps to reproduce 1. Install and enable Drupal Canvas; build the UI. 2. Create three code components: **Demo Button** (`demo_button`) — the shared leaf, no imports: ```jsx const Button = ({ label = "Click me" }) => { return <button className="demo-btn">{label}</button>; }; export default Button; ``` **Demo Card** (`demo_card`) — imports the Button: ```jsx import Button from "@/components/demo_button"; const Card = ({ title = "Card title", body = "Card body" }) => { return ( <div className="demo-card"> <h3>{title}</h3> <p>{body}</p> <Button label="Card action" /> </div> ); }; export default Card; ``` **Demo Pricing** (`demo_pricing`) — imports BOTH the Card and the Button: ```jsx import Button from "@/components/demo_button"; import Card from "@/components/demo_card"; const Pricing = ({ plan = "Pro", price = "$29/mo" }) => { return ( <section className="demo-pricing"> <h2>{plan}</h2> <p className="price">{price}</p> <Card title="What's included" body="All the features" /> <Button label="Buy now" /> </section> ); }; export default Pricing; ``` (`demo_button` sorts before `demo_card`, which is what triggers the bug.) 3. Place **Demo Pricing** on a page and view/preview it. 4. Observe the hydration error in the browser console (above); the Card and its button do not render. ### Expected The page hydrates. The intermediate (`Card`) resolves `@/components/demo_button` from its own import-map scope and renders its button. ### Actual Hydration throws `Failed to resolve module specifier "@/components/demo_button"` because the intermediate component has no scope of its own in the import map. ## Proposed resolution In `getScopedDependencies()`, treat `$seen` as the current recursion **path** (the ancestors of the node being processed) rather than a global visited set, and always write a component's own scope entry **before** the cycle check. This keeps cycle protection intact while ensuring every component maps all of its own direct imports — even when a dependency is shared across sibling branches. Workaround for sites that cannot patch: avoid importing a code component together with one of that component's own dependencies in the same parent (flatten the diamond), or import the shared leaf only through the intermediate. ## Remaining tasks - [x] Write a failing kernel test reproducing the diamond (`JsComponentTest::testImportMapsWithDiamondDependency()`). - [x] Apply the fix to `getScopedDependencies()`. - [x] Confirm the new test passes and the existing `testImportMaps` cases still pass (9 tests, 200 assertions). - [ ] Review whether `getDependencyLibraries()` (same `$seen` shape, just below) needs an analogous change — for libraries global dedup is desirable, so it is likely correct as-is, but confirm it cannot drop a needed library in a diamond. - [ ] Code review / maintainer review. ## User interface changes None. (Rendered/hydrated output is corrected; no UI text, paths, or features change.) ## Introduced terminology None. ## API changes None. `getScopedDependencies()` is a private method; its signature is unchanged. Only the contents of the generated import map are corrected. ## Data model changes None. ## Release notes snippet Fixed a bug where a code component that imported another code component while also directly importing one of that component's dependencies (a "diamond" dependency) produced an incomplete import map, causing the page to fail to hydrate with `Failed to resolve module specifier "@/components/…"`. ## Related issues - #3518191: Build import maps for code component dependencies, attach their CSS — introduced the `getScopedDependencies()` logic where this bug originates. - #3539147: `JsComponent::getScopedDependencies()` and `::getDependencyLibraries()` shouldn't get auto-saves unless previewing — same method, prior fix. - #3591643: Fix AssertionError crash during component tree reconstruction when a component fails hydration — downstream symptom; a component that fails to hydrate (as here) can trigger that crash. --- ### Environment - Drupal Canvas: 0.4.x (`drupal-canvas` 0.4.2) - Drupal core: 11.3.13 - PHP: 8.4
issue