Skip to content

Conversation

@draedful
Copy link
Collaborator

@draedful draedful commented Jul 7, 2025

Summary by Sourcery

Improve React strict mode compliance by cleaning up event listeners and subscriptions across layers and services, add customizable block list styling via new props, refactor event handling for clarity, and introduce a utility for parsing CSS class names with corresponding tests.

New Features:

  • Introduce blockListClassName and reactLayerRef props to ReactLayer and GraphCanvas for customizable styling and direct layer access
  • Add parseClassNames utility with corresponding tests for robust CSS class string parsing

Bug Fixes:

  • Ensure proper cleanup of event listeners and graph subscriptions to support React strict mode without memory leaks
  • Fix waitUsableRectUpdate to return a cleanup function and correct HitBox updateRect invocation
  • Resolve resize listener management in LayersService to avoid stale subscriptions and ensure accurate root size updates

Enhancements:

  • Refactor DevToolsLayer to utilize onGraphEvent and onRootEvent helpers for streamlined event handling
  • Replace throttle with debounce in LayersService resize handling and adjust frame timeouts for smoother rendering
  • Simplify BlocksList camera-change handling by inlining the update logic and removing unnecessary throttle memoization
  • Unify Layer unmount logic and correct device pixel ratio retrieval to rely on LayersService

Documentation:

  • Update React usage guide to document new blockListClassName and reactLayerRef props for GraphCanvas

Tests:

  • Add unit tests for the parseClassNames function to validate class name parsing behavior

@sourcery-ai
Copy link

sourcery-ai bot commented Jul 7, 2025

Reviewer's Guide

Standardizes event subscriptions with automatic cleanup, refactors resize and unmount logic across layers, hooks, and services for React Strict Mode compatibility, introduces styling props (blockListClassName, reactLayerRef) in ReactLayer and GraphCanvas, and adds a parseClassNames utility with documentation and tests.

Sequence diagram for updated useLayer hook lifecycle

sequenceDiagram
  actor ReactComponent
  participant useLayer
  participant Graph
  participant LayerInstance

  ReactComponent->>useLayer: mount
  useLayer->>Graph: addLayer(layerCtor, props)
  Graph-->>useLayer: LayerInstance
  useLayer->>useLayer: setLayer(LayerInstance)
  ReactComponent->>useLayer: unmount
  useLayer->>Graph: detachLayer(LayerInstance)
  Note over useLayer,Graph: Ensures cleanup on unmount
Loading

ER diagram for updated event subscription and cleanup in Graph, Layers, and Layer

erDiagram
  GRAPH ||--o{ LAYERS : manages
  LAYERS ||--o{ LAYER : contains
  LAYER }o--|| REACTLAYER : extends
  LAYERS ||--o{ RESIZEOBSERVER : uses
  LAYERS ||--o{ WINDOW : listens_to
  LAYER ||--o{ EVENT : subscribes
  LAYER ||--o{ ROOT_ELEMENT : listens_to
  GRAPH ||--o{ CAMERA_SERVICE : updates
  LAYER ||--o{ HTML_ELEMENT : applies_class
Loading

Class diagram for updated Layer and ReactLayer structure

classDiagram
  class Layer {
    +unmountLayer()
    +afterInit()
    +detachLayer()
    +getDRP()
    +unmount()
    +propsChanged(nextProps)
  }
  class ReactLayer {
    +afterInit()
    +propsChanged(nextProps)
    +applyBlockListClassName(oldClassName, newClassName)
    +blockListClassName: string
  }
  Layer <|-- ReactLayer
Loading

Class diagram for new parseClassNames utility

classDiagram
  class parseClassNames {
    <<function>>
    +parseClassNames(className: string): string[]
  }
Loading

File-Level Changes

Change Details Files
Refactor DevToolsLayer to use automatic event subscription methods
  • Replace manual DOM listeners with onRootEvent
  • Use onGraphEvent for camera-change
  • Remove custom unmount logic and listener cleanup
  • Call super.afterInit() after setup
src/plugins/devtools/DevToolsLayer.ts
Simplify BlocksList camera-change handling and cleanup
  • Inline throttle callback in useGraphEvent
  • Remove useMemo throttle creation
  • Ensure waitUsableRectUpdate is invoked directly
  • Cancel scheduled updates on unmount
src/react-components/BlocksList.tsx
Debounce window resize and streamline LayersService size updates
  • Switch from throttle to debounce for handleRootResize
  • Manage resize listener attach/detach in attach/detach
  • Simplify updateRootSize to always update dimensions
  • Cleanup observer and cancel handlers on unmount
src/services/LayersService.ts
Add styling and ref access props to ReactLayer/GraphCanvas
  • Support blockListClassName prop in ReactLayer with parseClassNames
  • Expose reactLayerRef in GraphCanvas to forward layer instance
  • Document new props in React usage guide
  • Export TRenderBlockFn from index
src/react-components/layer/ReactLayer.tsx
src/react-components/GraphCanvas.tsx
docs/react/usage.md
src/react-components/index.ts
Simplify Emitter destruction and remove redundant flags
  • Remove destroyed flag checks from off, emit, and destroy
  • Reset internal maps on destroy instead of marking destroyed
src/utils/Emitter.ts
Use effect-based lifecycle in useLayer hook to prevent leaks
  • Switch layer creation and cleanup from useLayoutEffect to useEffect
  • Manage layer instance in state and detach on cleanup
  • Retain prop change detection via usePrevious
src/react-components/hooks/useLayer.ts
Fix HitTest subscription return and HitBox update invocation
  • Return unsubscribe function from waitUsableRectUpdate
  • Avoid infinite recursion in usableRect update
  • Always call updateRect inside HitBox.update
src/services/HitTest.ts
Subscribe GraphLayer to store updates for blocks and connections
  • Register blocksList and connectionsList subscriptions in afterInit
  • Trigger performRender on store changes
src/components/canvas/layers/graphLayer/GraphLayer.ts
Unify Layer unmount logic and correct DPR retrieval
  • Invoke super.unmount() inside Layer.unmount
  • Adjust detachLayer to call unmountLayer only
  • Use graph.layers.getDPR() instead of devicePixelRatio
src/services/Layer.ts
Manage graph update-size listener lifecycle in Graph
  • Define named onUpdateSize handler
  • Attach listener on mount and remove on unmount
  • Store graph instance on root element symbol
src/graph.ts
Cleanup Graph on unmount in useGraph hook
  • Add useLayoutEffect cleanup to call graph.unmount()
  • Guard against missing external graph prop
src/react-components/hooks/useGraph.ts
Introduce parseClassNames utility with docs and tests
  • Implement parseClassNames to split and filter class strings
  • Re-export utility from functions index
  • Add unit tests for edge cases
  • Include Storybook preview configuration
src/utils/functions/classNames.ts
src/utils/functions/index.ts
src/utils/functions/classNames.test.ts
.storybook/preview.tsx

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

@gravity-ui-bot
Copy link
Contributor

Preview is ready.

@draedful draedful marked this pull request as ready for review July 9, 2025 09:50
@draedful draedful changed the title Fix-react-strict-mode fix: fix for react strict mode Jul 9, 2025
Copy link

@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 @draedful - I've reviewed your changes - here's some feedback:

  • Emitter: by removing the destroyed flag guards in off/emit/destroy, callbacks may still fire on a destroyed emitter—consider reintroducing a guard to prevent post-destroy emissions.
  • useLayer hook now creates and tears down layers inside a standard useEffect, which can delay initial props assignment and cause render mismatches in Strict Mode—consider using useLayoutEffect or synchronously setting props on mount.
  • LayersService switched from throttling+change-check to a 16ms debounce without verifying size changes, which may trigger redundant renders on every resize—consider restoring the change detection or fine-tuning the debounce parameters.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- Emitter: by removing the destroyed flag guards in off/emit/destroy, callbacks may still fire on a destroyed emitter—consider reintroducing a guard to prevent post-destroy emissions.
- useLayer hook now creates and tears down layers inside a standard useEffect, which can delay initial props assignment and cause render mismatches in Strict Mode—consider using useLayoutEffect or synchronously setting props on mount.
- LayersService switched from throttling+change-check to a 16ms debounce without verifying size changes, which may trigger redundant renders on every resize—consider restoring the change detection or fine-tuning the debounce parameters.

## Individual Comments

### Comment 1
<location> `src/utils/Emitter.ts:10` </location>
<code_context>
 const getTime = () => performance.now();
 type EmitterEventsDefinition = Record<string, (...args: unknown[]) => void>;
 export class Emitter<T extends EmitterEventsDefinition = EmitterEventsDefinition> {
-  private destroyed: boolean;
-
   private gcLaunched: boolean;
</code_context>

<issue_to_address>
The destroyed flag and related checks have been removed from Emitter.

This change allows methods like off, emit, and _walkGC to be called on a destroyed Emitter, which could cause unexpected behavior or memory leaks if handlers aren't cleaned up. Please confirm this is intentional and safe for all use cases.
</issue_to_address>

### Comment 2
<location> `src/react-components/hooks/useLayer.ts:38` </location>
<code_context>
     ? Omit<Props, "root" | "camera" | "graph" | "emitter"> & { root?: Props["root"] }
     : never
 ) {
-  const layer = useMemo(() => (graph ? graph.addLayer(layerCtor, props) : null), [graph]);
-  const prevProps = usePrevious(props);
+  const [layer, setLayer] = useState<InstanceType<T> | null>(null);

-  useLayoutEffect(() => {
</code_context>

<issue_to_address>
Layer instance is now managed via useState and useEffect instead of useMemo.

Using useState/useEffect may recreate the layer more frequently, potentially causing unnecessary detach/attach cycles. Please confirm this is intentional and won't impact performance or cause flicker.
</issue_to_address>

### Comment 3
<location> `src/react-components/layer/ReactLayer.tsx:35` </location>
<code_context>
-   * This is where we set up event subscriptions to ensure they work properly
-   * after the layer is unmounted and reattached.
-   */
-  protected afterInit(): void {
-    // Register event listener with the onGraphEvent method for automatic cleanup when unmounted
-    this.onGraphEvent("camera-change", this.performRender);
</code_context>

<issue_to_address>
Consider removing the manual class-toggling methods and letting React handle className updates in the render method.

```suggestion
You can remove the entire imperative class‐toggling logic (`afterInit` / `propsChanged` / `applyBlockListClassName`) and instead let React manage your `blockListClassName`. For example, wrap your `<BlocksList>` in a `<div>` with a dynamic `className` in the portal:

```ts
// 1) Delete these methods entirely:
//    protected afterInit() { … }
//    protected propsChanged() { … }
//    private applyBlockListClassName() { … }

// 2) Update renderPortal to delegate className to React:
public renderPortal(
  renderBlock: <T extends TBlock>(graphObject: Graph, block: T) => React.JSX.Element
) {
  const container = this.getHTML();
  if (!container) return null;

  return createPortal(
    <div className={this.props.blockListClassName}>
      <BlocksList
        graph={this.context.graph}
        renderBlock={renderBlock}
        /* keep other props here */
      />
    </div>,
    container
  );
}
```

This preserves all behavior—new `blockListClassName` will be applied/updated by React—while removing ~30 lines of manual DOM work.
</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.

const getTime = () => performance.now();
type EmitterEventsDefinition = Record<string, (...args: unknown[]) => void>;
export class Emitter<T extends EmitterEventsDefinition = EmitterEventsDefinition> {
private destroyed: boolean;
Copy link

Choose a reason for hiding this comment

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

question (bug_risk): The destroyed flag and related checks have been removed from Emitter.

This change allows methods like off, emit, and _walkGC to be called on a destroyed Emitter, which could cause unexpected behavior or memory leaks if handlers aren't cleaned up. Please confirm this is intentional and safe for all use cases.

? Omit<Props, "root" | "camera" | "graph" | "emitter"> & { root?: Props["root"] }
: never
) {
const layer = useMemo(() => (graph ? graph.addLayer(layerCtor, props) : null), [graph]);
Copy link

Choose a reason for hiding this comment

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

suggestion (performance): Layer instance is now managed via useState and useEffect instead of useMemo.

Using useState/useEffect may recreate the layer more frequently, potentially causing unnecessary detach/attach cycles. Please confirm this is intentional and won't impact performance or cause flicker.

});
}

protected afterInit(): void {
Copy link

Choose a reason for hiding this comment

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

issue (complexity): Consider removing the manual class-toggling methods and letting React handle className updates in the render method.

Suggested change
protected afterInit(): void {
You can remove the entire imperative class‐toggling logic (`afterInit` / `propsChanged` / `applyBlockListClassName`) and instead let React manage your `blockListClassName`. For example, wrap your `<BlocksList>` in a `<div>` with a dynamic `className` in the portal:
```ts
// 1) Delete these methods entirely:
// protected afterInit() { … }
// protected propsChanged() { … }
// private applyBlockListClassName() { … }
// 2) Update renderPortal to delegate className to React:
public renderPortal(
renderBlock: <T extends TBlock>(graphObject: Graph, block: T) => React.JSX.Element
) {
const container = this.getHTML();
if (!container) return null;
return createPortal(
<div className={this.props.blockListClassName}>
<BlocksList
graph={this.context.graph}
renderBlock={renderBlock}
/* keep other props here */
/>
</div>,
container
);
}

This preserves all behavior—new blockListClassName will be applied/updated by React—while removing ~30 lines of manual DOM work.

@draedful draedful force-pushed the fix-react-strict-mode branch 2 times, most recently from 42b5773 to 79d279b Compare July 9, 2025 11:09
…onsistency

- Moved from useEffect to useLayoutEffect in useLayer for immediate updates.
- Replaced window.devicePixelRatio with globalThis.devicePixelRatio for broader compatibility.
- Enhanced updateSize method in Layer to ensure proper rendering.
- Changed rootSize in LayersService to use signals for reactive updates.
- Switched debounce to throttle in handleRootResize for improved performance.
@draedful draedful force-pushed the fix-react-strict-mode branch from 79d279b to 6ce5804 Compare July 9, 2025 11:59
@draedful draedful merged commit 07ba2ee into main Jul 9, 2025
4 checks passed
This was referenced Jul 9, 2025
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.

3 participants