Skip to content

🐛 Bug: Callbacks skipped when using triggerOnce with merged refs #746

@djk01281

Description

@djk01281

Description

When multiple useInView({ triggerOnce: true }) hooks are merged on the same element, only some callbacks are executed. This seems to happen because the forEach loop in observe.ts is iterating over an array that is being modified during iteration via splice.

Reproduction

Add this test to the end of src/__tests__/useInView.test.tsx:

test("should trigger all hooks when using triggerOnce with merged refs", () => {
  const MultipleHooksWithTriggerOnce = () => {
    const { ref: ref1, inView: inView1 } = useInView({ triggerOnce: true });
    const { ref: ref2, inView: inView2 } = useInView({ triggerOnce: true });
    const { ref: ref3, inView: inView3 } = useInView({ triggerOnce: true });

    const setRefs = useCallback(
      (node: Element | null) => {
        ref1(node);
        ref2(node);
        ref3(node);
      },
      [ref1, ref2, ref3],
    );

    return (
      <div ref={setRefs}>
        <div data-testid="item-1" data-inview={inView1.toString()}>
          {inView1.toString()}
        </div>
        <div data-testid="item-2" data-inview={inView2.toString()}>
          {inView2.toString()}
        </div>
        <div data-testid="item-3" data-inview={inView3.toString()}>
          {inView3.toString()}
        </div>
      </div>
    );
  };

  const { getByTestId } = render(<MultipleHooksWithTriggerOnce />);

  mockAllIsIntersecting(true);

  expect(getByTestId("item-1").getAttribute("data-inview")).toBe("true");
  expect(getByTestId("item-2").getAttribute("data-inview")).toBe("true"); // ❌ FAILS: expected "true" but got "false"
  expect(getByTestId("item-3").getAttribute("data-inview")).toBe("true");
});
Image

Expected behavior: All three hooks should receive inView: true when the element enters the viewport.

Actual behavior: The test fails on item-2. Only the first and third hooks are triggered, the second hook remains false.

Root Cause

In src/observe.ts:86-88:

elements.get(entry.target)?.forEach((callback) => {
  callback(inView, entry);
});

When a callback executes with triggerOnce: true, it calls unobserve() which performs:

callbacks.splice(callbacks.indexOf(callback), 1);

This modifies the array during the forEach iteration, causing subsequent callbacks to be skipped.

Proposed Solution

Copy the callbacks array before iterating using the spread operator:

// src/observe.ts:86
[...elements.get(entry.target) ?? []].forEach((callback) => {
  callback(inView, entry);
});

Alternative: use .slice():

elements.get(entry.target)?.slice().forEach((callback) => {
  callback(inView, entry);
});

Environment

  • Version: v10.0.2 (current main branch)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions