Skip to content

fix(components): AbortController lifecycle + focus traps for interactive controls#118

Merged
4444J99 merged 1 commit into
mainfrom
fix/lifecycle-focus-traps
May 24, 2026
Merged

fix(components): AbortController lifecycle + focus traps for interactive controls#118
4444J99 merged 1 commit into
mainfrom
fix/lifecycle-focus-traps

Conversation

@4444J99
Copy link
Copy Markdown
Owner

@4444J99 4444J99 commented May 24, 2026

Addresses the lifecycle/focus-trap findings in four interactive controls.

All four re-bound listeners on every astro:page-load/after-swap with no AbortController, accumulating handlers on persistent elements (notably the header's DepthControl button) and leaking a document keydown in the onboarding modal.

  • DepthControl, ControlsBentoCell, GovernancePreview — listeners now scoped to an AbortController that is aborted + recreated on each page-load (matches the project's enforced pattern).
  • ShibuiOnboarding — controller scoped to the panel; aborted on dismiss and on before-swap; added a guard against double-wiring.
  • Focus traps — ShibuiOnboarding now traps Tab + Escape with initial focus and focus restore on close; GovernancePreview's trap retained and scoped.
  • Scroll-lock leak — GovernancePreview resets document.body.style.overflow on before-swap, so navigating away with the modal open can't leave the next page scroll-locked.

Test plan

  • npm run lint / typecheck:strict — clean / 0 hints
  • npm run build
  • component tests — 72/72 (incl. listener-lifecycle.test.ts)
  • Focus-trap behavior implemented to the standard pattern but not browser-verified here.

https://claude.ai/code/session_01PW9DnyVijUNmUJ4qMfgpHn


Generated by Claude Code

Summary by Sourcery

Ensure interactive controls clean up event listeners correctly across Astro page transitions and improve modal focus handling.

Bug Fixes:

  • Prevent duplicate event listeners and leaks in ShibuiOnboarding, DepthControl, ControlsBentoCell, and GovernancePreview by scoping handlers to AbortControllers tied to page lifecycle.
  • Fix ShibuiOnboarding modal to restore focus on close and avoid double-initialization when reinvoked.
  • Ensure GovernancePreview clears body scroll-lock and aborts its listeners when navigating away or before Astro swaps.

Enhancements:

  • Add proper focus trapping, initial focus, and Escape handling to the ShibuiOnboarding modal consistent with the project's modal behavior patterns.

…ive controls

DepthControl, ControlsBentoCell, GovernancePreview, and ShibuiOnboarding
re-bound listeners on every astro:page-load/after-swap with no
AbortController, accumulating handlers on persistent elements (e.g. the
header DepthControl button) and leaking a document keydown in the
onboarding modal.

- All four now scope listeners to an AbortController that is aborted and
  recreated on each page-load (DepthControl/ControlsBentoCell/Governance)
  or torn down on before-swap (Onboarding), matching the project's
  established lifecycle pattern enforced by listener-lifecycle.test.ts.
- ShibuiOnboarding + GovernancePreview: added/retained Escape + Tab focus
  traps with initial focus and focus restore on close.
- GovernancePreview: reset document.body.style.overflow on before-swap so
  navigating away with the modal open can't leave the next page
  scroll-locked.

Verified: lint, typecheck:strict (0 hints), build, component tests 72/72
(incl. listener-lifecycle). Focus-trap behavior is implemented to the
standard pattern but not browser-verified here.

https://claude.ai/code/session_01PW9DnyVijUNmUJ4qMfgpHn
@sourcery-ai
Copy link
Copy Markdown

sourcery-ai Bot commented May 24, 2026

Reviewer's Guide

Implements AbortController-scoped event listener lifecycles for four interactive controls, adds robust focus-trap and focus-restore behavior to onboarding and governance modals, and ensures scroll-lock is properly cleaned up across Astro page transitions.

Sequence diagram for ShibuiOnboarding focus-trap and lifecycle

sequenceDiagram
  title ShibuiOnboarding focus-trap and lifecycle
  actor User
  participant Document
  participant ShibuiOnboarding as ShibuiOnboardingPanel
  participant OnboardingController as AbortController

  Document->>ShibuiOnboarding: initOnboarding
  ShibuiOnboarding->>OnboardingController: new AbortController
  ShibuiOnboarding->>Document: addEventListener keydown { signal }
  ShibuiOnboarding->>ShibuiOnboarding: store previouslyFocused
  ShibuiOnboarding->>ShibuiOnboarding: collect focusables
  ShibuiOnboarding->>ShibuiOnboarding: panel.classList.add visible
  ShibuiOnboarding->>ShibuiOnboarding: focus first focusable

  rect rgb(230,230,250)
    User->>Document: keydown Escape
    Document->>ShibuiOnboarding: dismiss
    ShibuiOnboarding->>OnboardingController: abort
    ShibuiOnboarding->>ShibuiOnboarding: panel.classList.add hiding
    ShibuiOnboarding->>ShibuiOnboarding: panel.remove
    ShibuiOnboarding->>User: previouslyFocused.focus
  end

  rect rgb(230,250,230)
    User->>Document: keydown Tab
    alt [focus outside panel]
      Document->>ShibuiOnboarding: focus first focusable
    else [Shift+Tab on first]
      Document->>ShibuiOnboarding: focus last focusable
    else [Tab on last]
      Document->>ShibuiOnboarding: focus first focusable
    end
  end

  rect rgb(250,230,230)
    Document->>ShibuiOnboarding: teardownOnboarding (astro:before-swap)
    ShibuiOnboarding->>OnboardingController: abort
  end
Loading

File-Level Changes

Change Details Files
Scope ShibuiOnboarding event listeners with an AbortController and implement a proper modal focus trap + teardown.
  • Introduce a module-level onboardingController to prevent double initialization and centralize listener teardown.
  • Wrap click and keydown listeners for onboarding cards, skip button, backdrop, and document keydown in an AbortController signal.
  • Implement focus trapping within the onboarding panel for Tab navigation, including initial focus on open and restoring focus to the previously active element on dismiss.
  • Add teardownOnboarding invoked on astro:before-swap and guard initOnboarding against re-wiring when already initialized.
src/components/shibui/ShibuiOnboarding.astro
Refactor DepthControl and bridge expanders to use a shared AbortController per page-load lifecycle.
  • Introduce a depthController that is aborted and recreated on each astro:page-load.
  • Update initDepthControl and initBridgeExpanders to accept an AbortSignal and register all click/keydown handlers with that signal.
  • Ensure DepthControl button click and keyboard handlers, and bridge expander click handlers, are cleaned up on subsequent navigations.
src/components/shibui/DepthControl.astro
Lifecycle-manage GovernancePreview modal listeners with an AbortController, preserve its focus trap, and fix scroll-lock leaks across navigations.
  • Introduce a govController that is created on astro:page-load, passed as a signal into setupGovPreview, and aborted before swaps.
  • Attach trigger, close button, preview keydown, and preview click handlers using the AbortSignal to avoid accumulating listeners.
  • On astro:before-swap, abort the controller, clear it, and reset document.body.style.overflow to release scroll-lock if navigating away with the modal open.
src/components/resume/GovernancePreview.astro
Scope ControlsBentoCell view-toggle wiring to an AbortController per page-load.
  • Introduce a bentoController that is aborted and recreated on each astro:page-load.
  • Update initBentoViewToggle to accept an AbortSignal and register button click handlers with that signal so they are torn down on subsequent loads.
src/components/home/ControlsBentoCell.astro

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Copy Markdown

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="src/components/shibui/ShibuiOnboarding.astro" line_range="144-147" />
<code_context>
+      { signal },
+    );

     requestAnimationFrame(() => {
       panel.classList.add('shibui-onboarding--visible');
+      focusables[0]?.focus();
     });
   }
</code_context>
<issue_to_address>
**suggestion:** Guard the rAF callback against the panel being dismissed before it runs.

If the onboarding is dismissed or the route changes before the `requestAnimationFrame` callback runs, this code may call `classList.add`/`focus()` on detached elements. Check `signal.aborted` or `panel.isConnected` inside the callback and return early before mutating or focusing, e.g.:

```ts
requestAnimationFrame(() => {
  if (signal.aborted || !panel.isConnected) return;
  panel.classList.add('shibui-onboarding--visible');
  focusables[0]?.focus();
});
```

```suggestion
    requestAnimationFrame(() => {
      if (signal.aborted || !panel.isConnected) return;
      panel.classList.add('shibui-onboarding--visible');
      focusables[0]?.focus();
    });
```
</issue_to_address>

### Comment 2
<location path="src/components/resume/GovernancePreview.astro" line_range="89-90" />
<code_context>
+  document.addEventListener('astro:before-swap', () => {
+    govController?.abort();
+    govController = null;
+    // Reset the scroll-lock in case we navigate away while the modal is open.
+    document.body.style.overflow = '';
+  });
 </script>
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Restoring `body.style.overflow` unconditionally may clobber a pre-existing overflow style.

This handler assumes the modal is the only code modifying `body.style.overflow`. If any other feature sets a non-empty overflow on `<body>`, this will be cleared on navigation. Consider capturing the prior value when you first apply the scroll lock and restoring that value here, instead of always assigning an empty string.

Suggested implementation:

```
  // Track the body's previous overflow so we can restore it when removing scroll-lock.
  let previousBodyOverflow: string | null = null;

  document.addEventListener('astro:page-load', () => {

```

```
  document.addEventListener('astro:before-swap', () => {
    govController?.abort();
    govController = null;
    // Reset the scroll-lock in case we navigate away while the modal is open.
    if (previousBodyOverflow !== null) {
      document.body.style.overflow = previousBodyOverflow;
      previousBodyOverflow = null;
    }
  });

```

`).

Here are the concrete edits:

<file_operations>
<file_operation operation="edit" file_path="src/components/resume/GovernancePreview.astro">
<<<<<<< SEARCH
  document.addEventListener('astro:page-load', () => {
=======
  // Track the body's previous overflow so we can restore it when removing scroll-lock.
  let previousBodyOverflow: string | null = null;

  document.addEventListener('astro:page-load', () => {
>>>>>>> REPLACE

<<<<<<< SEARCH
  document.addEventListener('astro:before-swap', () => {
    govController?.abort();
    govController = null;
    // Reset the scroll-lock in case we navigate away while the modal is open.
    document.body.style.overflow = '';
  });
=======
  document.addEventListener('astro:before-swap', () => {
    govController?.abort();
    govController = null;
    // Reset the scroll-lock in case we navigate away while the modal is open.
    if (previousBodyOverflow !== null) {
      document.body.style.overflow = previousBodyOverflow;
      previousBodyOverflow = null;
    }
  });
>>>>>>> REPLACE
</file_operation>
</file_operations>

<additional_changes>
To fully implement the behavior you described, you should also:
1. Locate the code in this component (or its helpers such as `setupGovPreview`) that applies the scroll lock, e.g. any `document.body.style.overflow = 'hidden'` or similar.
2. Before changing `document.body.style.overflow`, capture the existing value if it hasn't been captured yet:
   - `if (previousBodyOverflow === null) previousBodyOverflow = document.body.style.overflow;`
   - Then apply the scroll lock: `document.body.style.overflow = 'hidden';`
3. When the modal is closed (not just on navigation), restore `previousBodyOverflow` in the same way as in the `astro:before-swap` handler and reset it to `null` so the next lock cycle correctly recaptures the current value.
This ensures you never clobber a pre-existing overflow style and that the value is properly restored on both modal close and page navigation.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines 144 to 147
requestAnimationFrame(() => {
panel.classList.add('shibui-onboarding--visible');
focusables[0]?.focus();
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Guard the rAF callback against the panel being dismissed before it runs.

If the onboarding is dismissed or the route changes before the requestAnimationFrame callback runs, this code may call classList.add/focus() on detached elements. Check signal.aborted or panel.isConnected inside the callback and return early before mutating or focusing, e.g.:

requestAnimationFrame(() => {
  if (signal.aborted || !panel.isConnected) return;
  panel.classList.add('shibui-onboarding--visible');
  focusables[0]?.focus();
});
Suggested change
requestAnimationFrame(() => {
panel.classList.add('shibui-onboarding--visible');
focusables[0]?.focus();
});
requestAnimationFrame(() => {
if (signal.aborted || !panel.isConnected) return;
panel.classList.add('shibui-onboarding--visible');
focusables[0]?.focus();
});

Comment on lines +89 to +90
// Reset the scroll-lock in case we navigate away while the modal is open.
document.body.style.overflow = '';
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): Restoring body.style.overflow unconditionally may clobber a pre-existing overflow style.

This handler assumes the modal is the only code modifying body.style.overflow. If any other feature sets a non-empty overflow on <body>, this will be cleared on navigation. Consider capturing the prior value when you first apply the scroll lock and restoring that value here, instead of always assigning an empty string.

Suggested implementation:

  // Track the body's previous overflow so we can restore it when removing scroll-lock.
  let previousBodyOverflow: string | null = null;

  document.addEventListener('astro:page-load', () => {

  document.addEventListener('astro:before-swap', () => {
    govController?.abort();
    govController = null;
    // Reset the scroll-lock in case we navigate away while the modal is open.
    if (previousBodyOverflow !== null) {
      document.body.style.overflow = previousBodyOverflow;
      previousBodyOverflow = null;
    }
  });

`).

Here are the concrete edits:

<file_operations>
<file_operation operation="edit" file_path="src/components/resume/GovernancePreview.astro">
<<<<<<< SEARCH
document.addEventListener('astro:page-load', () => {

// Track the body's previous overflow so we can restore it when removing scroll-lock.
let previousBodyOverflow: string | null = null;

document.addEventListener('astro:page-load', () => {

REPLACE

<<<<<<< SEARCH
document.addEventListener('astro:before-swap', () => {
govController?.abort();
govController = null;
// Reset the scroll-lock in case we navigate away while the modal is open.
document.body.style.overflow = '';
});

document.addEventListener('astro:before-swap', () => {
govController?.abort();
govController = null;
// Reset the scroll-lock in case we navigate away while the modal is open.
if (previousBodyOverflow !== null) {
document.body.style.overflow = previousBodyOverflow;
previousBodyOverflow = null;
}
});

REPLACE
</file_operation>
</file_operations>

<additional_changes>
To fully implement the behavior you described, you should also:

  1. Locate the code in this component (or its helpers such as setupGovPreview) that applies the scroll lock, e.g. any document.body.style.overflow = 'hidden' or similar.
  2. Before changing document.body.style.overflow, capture the existing value if it hasn't been captured yet:
    • if (previousBodyOverflow === null) previousBodyOverflow = document.body.style.overflow;
    • Then apply the scroll lock: document.body.style.overflow = 'hidden';
  3. When the modal is closed (not just on navigation), restore previousBodyOverflow in the same way as in the astro:before-swap handler and reset it to null so the next lock cycle correctly recaptures the current value.
    This ensures you never clobber a pre-existing overflow style and that the value is properly restored on both modal close and page navigation.

@4444J99 4444J99 merged commit 4879fda into main May 24, 2026
3 checks passed
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces AbortController logic across multiple interactive components—ControlsBentoCell, GovernancePreview, DepthControl, and ShibuiOnboarding—to properly manage event listeners during page transitions. It also enhances the onboarding modal with focus trapping and better lifecycle management. The review feedback highlights the need for consistent adherence to the project's style guide regarding Astro lifecycle events, specifically recommending the use of astro:page-load for initialization and ensuring astro:before-swap is used for explicit teardown in all components to prevent memory leaks or stale event handlers.

Comment on lines 156 to +157
document.addEventListener('astro:after-swap', initOnboarding);
document.addEventListener('astro:before-swap', teardownOnboarding);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

For consistency with the other interactive components in this PR and to adhere to the project's preferred pattern (as noted in the Repository Style Guide, line 65), consider using astro:page-load instead of separate initOnboarding and astro:after-swap calls. This centralizes initialization and ensures it runs correctly both on initial load and after view transitions.

  document.addEventListener('astro:page-load', initOnboarding);
  document.addEventListener('astro:before-swap', teardownOnboarding);
References
  1. Uses Astro's . Use astro:page-load for initialization and astro:before-swap for teardown. (link)

Comment on lines +124 to +128
document.addEventListener('astro:page-load', () => {
bentoController?.abort();
bentoController = new AbortController();
initBentoViewToggle(bentoController.signal);
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

While astro:page-load correctly handles re-initialization and prevents handler accumulation, it is best practice to also provide an explicit teardown on astro:before-swap to ensure total cleanup when navigating away from the page, especially since this component uses a module-level AbortController.

document.addEventListener('astro:page-load', () => {
	bentoController?.abort();
	bentoController = new AbortController();
	initBentoViewToggle(bentoController.signal);
});

document.addEventListener('astro:before-swap', () => {
	bentoController?.abort();
	bentoController = null;
});
References
  1. Use astro:page-load for initialization and astro:before-swap for teardown. (link)

Comment on lines 186 to 191
document.addEventListener('astro:page-load', () => {
initDepthControl();
initBridgeExpanders();
depthController?.abort();
depthController = new AbortController();
initDepthControl(depthController.signal);
initBridgeExpanders(depthController.signal);
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to ControlsBentoCell, this component should implement an astro:before-swap listener to explicitly abort the depthController when the page is swapped. This ensures that all listeners (including those on the global document object, if any are added in the future) are properly cleaned up.

  document.addEventListener('astro:page-load', () => {
    depthController?.abort();
    depthController = new AbortController();
    initDepthControl(depthController.signal);
    initBridgeExpanders(depthController.signal);
  });

  document.addEventListener('astro:before-swap', () => {
    depthController?.abort();
    depthController = null;
  });
References
  1. Use astro:page-load for initialization and astro:before-swap for teardown. (link)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants