From 5f83b8b6a1d2beda700704f68fb5dcdf282997ea Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Sun, 24 May 2026 21:31:07 +0200 Subject: [PATCH 01/14] docs --- .../react-overflow/library/README.md | 6 + .../library/docs/overflow-algorithm.md | 1263 +++++++++++++++++ .../library/docs/overflow-northstar.md | 404 ++++++ .../docs/react-overflow-react-bridge.md | 264 ++++ 4 files changed, 1937 insertions(+) create mode 100644 packages/react-components/react-overflow/library/docs/overflow-algorithm.md create mode 100644 packages/react-components/react-overflow/library/docs/overflow-northstar.md create mode 100644 packages/react-components/react-overflow/library/docs/react-overflow-react-bridge.md diff --git a/packages/react-components/react-overflow/library/README.md b/packages/react-components/react-overflow/library/README.md index 2ef4d3e7c876cb..dc84b62f4d7b14 100644 --- a/packages/react-components/react-overflow/library/README.md +++ b/packages/react-components/react-overflow/library/README.md @@ -3,3 +3,9 @@ **React Priority Overflow components for [Fluent UI React](https://react.fluentui.dev)** These are not production-ready components and **should never be used in product**. This space is useful for testing new components whose APIs might change before final release. + +For internal notes on overflow, keep the structure intentionally small: + +- [docs/overflow-algorithm.md](./docs/overflow-algorithm.md) — engine spec +- [docs/react-overflow-react-bridge.md](./docs/react-overflow-react-bridge.md) — React bridge spec +- [docs/overflow-northstar.md](./docs/overflow-northstar.md) — temporary working document for future changes diff --git a/packages/react-components/react-overflow/library/docs/overflow-algorithm.md b/packages/react-components/react-overflow/library/docs/overflow-algorithm.md new file mode 100644 index 00000000000000..0f5b2e9cb7e401 --- /dev/null +++ b/packages/react-components/react-overflow/library/docs/overflow-algorithm.md @@ -0,0 +1,1263 @@ +# Priority Overflow Engine Spec + +This document is the working specification for the `@fluentui/priority-overflow` engine that sits underneath `@fluentui/react-overflow`. + +It is intentionally focused on the engine itself: queues, measurement, lifecycle, invariants, and browser cost. + +## Scope + +- Engine package: `packages/react-components/priority-overflow/src/` +- Main engine entry point: `createOverflowManager()` + +For the React integration layer, see `docs/react-overflow-react-bridge.md`. + +For open design directions, refactor ideas, and unresolved questions, see `docs/overflow-northstar.md`. + +## One-sentence model + +The engine keeps two priority queues of item ids, measures the container and registered elements, then repeatedly moves items between the visible and invisible queues until occupied size fits within available size. + +## Core concepts + +### Overflow item + +An overflow item is a registered DOM element with: + +- `id`: stable identity +- `priority`: lower priority hides earlier +- `pinned`: never overflow +- `groupId`: optional grouping key + +### Overflow menu + +An optional DOM element whose size is only counted when at least one item is hidden, or when `hasHiddenItems` is true. This lets the engine reserve room for a "more" button. + +### Divider + +An optional group-owned element whose visibility follows the group. Dividers are counted in occupied size only when the group is at least partially visible. + +### Visible and invisible queues + +The engine stores ids in two heaps: + +- `visibleItemQueue`: the next candidate to hide is at the top +- `invisibleItemQueue`: the next candidate to show is at the top + +The comparator uses three rules: + +1. Pinned items rank above non-pinned items. +2. Higher `priority` ranks above lower `priority`. +3. Ties are broken by DOM order using `compareDocumentPosition`, with `overflowDirection` deciding whether the start or end of the container hides first. + +## Flow overview + +```mermaid +flowchart TD + A[Register container and options] --> B[Register items, menu, dividers] + B --> C[ResizeObserver or manual update] + C --> D[Microtask debounce] + D --> E[Read container client size] + E --> F[Read item, divider, and menu sizes with cache] + F --> G[Rebalance queues by priority and fit] + G --> H[Mark items and dividers overflowing or visible] + H --> I[Dispatch visibleItems, invisibleItems, groupVisibility] + I --> J[Consumer layer applies visibility changes] +``` + +## Lifecycle + +```mermaid +stateDiagram-v2 + [*] --> Idle + Idle --> Observing: observe(container, options) + Observing --> Dirty: addItem / removeItem / resize / manual update + Dirty --> Scheduled: update() + Scheduled --> Processing: microtask runs forceUpdate() + Processing --> Observing: state stabilized and callback dispatched + Observing --> Idle: disconnect() +``` + +## Detailed lifecycle + +### 1. Construction + +`createOverflowManager()` allocates: + +- the visible and invisible priority queues +- a per-pass size cache `Map` +- item and divider registries +- a group manager for `visible | hidden | overflow` +- mutable options and container references + +At this point, nothing is observed and no DOM reads happen. + +### 2. Observation starts + +`observe(container, options)`: + +- copies user options into the live options object +- moves all already-known items into the visible queue +- stores the container reference +- starts a `ResizeObserver` on the container + +Important: the engine does not discover DOM nodes by itself. Items must be registered explicitly. + +### 3. Registration churn + +`addItem`, `removeItem`, `addOverflowMenu`, and `addDivider` mutate the engine model. + +- `addItem` inserts an item into the registry and queue, updates group membership, and schedules `update()` +- `removeItem` removes it from both queues and schedules `update()` +- `addOverflowMenu` only stores a reference; the menu enters size calculations later +- `addDivider` stores a divider keyed by `groupId` + +### 4. Update scheduling + +`update()` is microtask-debounced in production. Multiple resize and registration events in the same tick collapse into one `forceUpdate()` run. + +That means the lifecycle is usually: + +1. DOM changes happen +2. `ResizeObserver` fires or code calls `update()` +3. a microtask is queued +4. the queued task performs one rebalance pass + +### 5. Processing pass + +`forceUpdate()` calls `processOverflowItems()`. + +The algorithm: + +1. Clears the size cache. +2. Reads available size as `container.clientWidth` or `clientHeight` minus `padding`. +3. Repairs priority ordering if the best hidden item outranks the worst visible item. +4. Runs two show/hide rounds to stabilize the state. +5. Dispatches only if queue tops changed, or if `forceDispatch` was set. + +The two-round design matters when a new item is added as visible by default and the first pass has not yet settled into the best visible/invisible split. + +## Rebalance algorithm + +```mermaid +flowchart TD + A[Start processOverflowItems] --> B[Clear size cache] + B --> C[availableSize = container.clientSize - padding] + C --> D{Best hidden outranks worst visible?} + D -- yes --> E[Hide worst visible] + E --> D + D -- no --> F[Round 1] + F --> G{occupiedSize < availableSize or only one hidden item left?} + G -- yes --> H[Show best hidden] + H --> G + G -- no --> I{occupiedSize > availableSize and visible count > minimumVisible?} + I -- yes --> J{Next visible item pinned?} + J -- yes --> K[Stop hiding] + J -- no --> L[Hide worst visible] + L --> I + I -- no --> M[Round 2] + K --> M + M --> N[Repeat show loop then hide loop] + N --> O[If state changed, dispatch update] +``` + +## Occupied size model + +`occupiedSize()` computes: + +- sum of `offsetWidth` or `offsetHeight` for all visible items +- plus visible dividers for visible or partially visible groups +- plus overflow menu size when any item is hidden, or when `hasHiddenItems` is true + +This is the central fit function: + +$$ +occupied = \sum visibleItems size(item) + \sum activeDividers size(divider) + overflowMenuSize +$$ + +and the container is considered stable when: + +$$ +occupied \le availableSize +$$ + +## Group lifecycle + +Grouping is a thin state machine layered on top of the base queue model. + +Each group tracks two sets: + +- `visibleItemIds` +- `invisibleItemIds` + +Its visibility state is: + +- `visible`: at least one visible item and no hidden items +- `hidden`: no visible items +- `overflow`: both visible and hidden items exist + +Divider behavior is tied to the last visible item in a group: + +- when hiding the last visible item, the divider gets `data-overflowing` +- when showing the first visible item again, the divider becomes visible again + +## Pinning model + +Pinning is not a separate algorithm. It is a comparator rule plus a stop condition: + +- pinned items compare above all non-pinned items +- if the next candidate to hide is pinned, the hide loop stops + +So pinning affects queue ordering but does not add new passes or new DOM reads. + +## Browser pipeline cost model + +The engine does work in three layers: + +### 1. JavaScript / scheduler cost + +This includes: + +- queue enqueue, dequeue, peek, remove +- set and map updates for groups and size cache +- DOM attribute writes for visibility markers +- callback dispatch + +This is the predictable, CPU-bound part. + +### 2. Layout-related cost + +This comes from reading: + +- `container.clientWidth` or `clientHeight` +- item `offsetWidth` or `offsetHeight` +- divider size +- overflow menu size + +These properties can force layout if the DOM or styles are dirty at the time of the read. + +Important nuance: when the update was triggered by `ResizeObserver`, the browser has already performed layout for the size change that caused the observer callback. In that path, many reads are likely to hit already-computed geometry. When updates are triggered directly after DOM writes, reads are more likely to force a synchronous layout. + +### 3. Style, paint, and composite cost + +The engine itself mostly writes attributes such as `data-overflowing`. In the React wrapper, those attributes map to `display: none` for overflowing items. + +That means the engine can cause: + +- style recalculation because selectors depend on attributes +- layout because `display: none` changes box participation +- paint because visible content changed +- sometimes compositing updates, depending on the surrounding page + +There is no animation or transform-based hiding in the base model, so hiding an item is usually a layout-affecting change, not a compositor-only change. + +## Complexity summary + +Let: + +- `n` = number of items +- `g` = number of groups with dividers +- `k` = number of items that change visibility in one pass + +### Heap operations + +- `enqueue` and `dequeue`: $O(\log n)$ +- `peek`: $O(1)$ +- `remove`: $O(n)$ because it uses `indexOf` before heapify + +### Per-pass size computation + +`occupiedSize()` walks: + +- all currently visible items +- all groups to decide visible divider contribution + +So each `occupiedSize()` call is $O(v + g)$ where `v` is the visible item count, worst-case $O(n + g)$. + +### Full rebalance pass + +The show/hide loops can call `occupiedSize()` many times. In the worst case, if many items toggle during one pass, total JS work trends toward quadratic behavior because the algorithm repeatedly recomputes aggregate occupied size from scratch. + +Conservative worst-case bound for a large rebalance: + +$$ +O(k \cdot (n + g) + k \log n) +$$ + +and when `k \approx n`, that behaves like roughly $O(n^2)$ JS work for the pass. + +This is the most important cost characteristic of the base model. + +## Feature cost breakdown + +### Base model cost + +The base model includes: + +- two heaps +- size cache clearing per pass +- repeated aggregate size computation +- queue moves and callback dispatch + +This is where nearly all algorithmic cost lives. + +### Grouping cost + +Grouping adds: + +- one extra map lookup per item move +- one or two `Set` mutations per moved item +- group visibility recomputation for that group, which is $O(1)$ +- divider visibility attribute writes +- divider contribution in `occupiedSize()`, which adds an $O(g)$ scan over groups + +Impact assessment: + +- JS overhead per moved item: low +- additional DOM reads: only divider size when divider is active +- aggregate pass overhead: moderate when `g` is large because every `occupiedSize()` call scans groups + +### Pinning cost + +Pinning adds: + +- one boolean branch inside item comparison +- one boolean guard in the hide loop + +Impact assessment: + +- JS overhead: negligible +- DOM overhead: none directly +- practical effect: can reduce work if many items become unhideable early, but can also leave the layout in an overflowed state if pinned items consume too much space + +### Overflow menu cost + +The menu adds: + +- one more measured element when overflow exists +- one threshold effect: showing the last hidden item may remove the menu itself, so the algorithm intentionally attempts to reveal the last hidden item even if it might initially appear not to fit + +Impact assessment: + +- JS overhead: low +- layout sensitivity: moderate because the menu size changes the equilibrium point + +### Minimum visible cost + +`minimumVisible` only adds a numeric stop condition in the hide loop. + +Impact assessment: + +- JS overhead: negligible +- DOM overhead: none directly +- functional effect: may allow the container to remain overflowed if the minimum is too high to satisfy the physical space available + +## When does it force layout? + +High-risk cases for synchronous layout: + +- add or remove many items, then call `update()` in the same tick +- mutate styles or classes affecting widths before the microtask runs +- read overflow state and then synchronously mutate layout again in response + +Lower-risk cases: + +- pure container resizes observed through `ResizeObserver` +- repeated measurements in the same pass, because `sizeCache` prevents duplicate width and height reads for the same element + +## Why the size cache helps + +Without the size cache, `occupiedSize()` would re-read the same geometry repeatedly during one rebalance pass. Since the loops can call `occupiedSize()` several times, repeated `offsetWidth` and `clientWidth` reads would inflate both scripting time and layout risk. + +The cache reduces repeated geometry reads inside one pass to roughly one read per involved element. + +It does not reduce: + +- the number of items iterated in each `occupiedSize()` call +- style/layout invalidation caused by visibility changes +- cross-pass reads after subsequent updates + +## Mental model for performance + +If you want a quick mental budget: + +- registration-heavy churn costs are mostly queue mutation plus some linear `remove` work +- resize-heavy steady state costs are mostly repeated aggregate size scans +- the expensive browser part is not the heap; it is geometry reads plus `display: none` toggles that can trigger layout and paint + +In practical terms, the engine is usually fine for toolbar-sized collections. The model becomes more expensive when all of these are true at once: + +- many items +- frequent width changes +- many items cross the threshold on each update +- many groups and active dividers +- updates happen while layout is already dirty + +## Scenario atlas + +This section turns the abstract algorithm into concrete situations. The goal is to answer two questions for each case: + +1. What does the engine do? +2. Where does the browser spend time? + +### Scenario 1: Initial mount and everything fits + +This is the cheapest successful path. + +```mermaid +sequenceDiagram + participant App as App code + participant Wrapper as React wrapper + participant Engine as Overflow manager + participant Browser as Browser + + App->>Wrapper: mount container and items + Wrapper->>Engine: observe(container) + Wrapper->>Engine: addItem(A..N) + Engine->>Engine: queue all items as visible + Browser-->>Engine: ResizeObserver callback + Engine->>Browser: read container and item sizes + Engine->>Engine: occupiedSize <= availableSize + Engine-->>Wrapper: dispatch all visible + Wrapper->>Browser: no data-overflowing writes needed +``` + +Functionally: + +- all items start visible +- the first processing pass confirms they fit +- the overflow menu stays size-neutral if no items are hidden and `hasHiddenItems` is false + +Cost profile: + +- JS: low to moderate, mostly registration plus one measurement pass +- layout: one container read plus one read per item +- paint: minimal because nothing gets hidden after the pass +- composite: negligible + +Interesting nuance: + +- this path still measures everything once +- the first mount forces a dispatch even if queue tops did not change, because `forceDispatch` starts as true + +### Scenario 2: Initial mount already overflows + +This is the first truly interesting case because the engine has to discover the stable split between visible and hidden items. + +```mermaid +flowchart TD + A[Mount with all items visible by default] --> B[Read availableSize] + B --> C[Compute occupiedSize] + C --> D{occupiedSize > availableSize?} + D -- yes --> E[Hide lowest-ranked visible item] + E --> F[Mark item data-overflowing] + F --> G[Recompute occupiedSize] + G --> D + D -- no --> H[Dispatch visible and invisible lists] +``` + +Functionally: + +- all items begin life in the visible queue +- the hide loop repeatedly removes the worst visible candidate +- once the first item hides, the overflow menu may enter the occupied-size equation, which can force one more hide than a naive width sum would suggest + +Cost profile: + +- JS: proportional to how many items need to be hidden +- layout: repeated size reads, but many are cached within the pass +- paint: visible because items transition to `display: none` +- composite: still minor; the expensive step is layout and repaint + +This is often the most expensive single mount-time case. + +### Scenario 3: Container shrinks gradually + +This is the steady-state resize path most people care about for toolbars. + +```mermaid +sequenceDiagram + participant Browser as Browser + participant Engine as Overflow manager + participant Wrapper as React wrapper + + Browser-->>Engine: ResizeObserver callback + Engine->>Engine: debounce update in microtask + Engine->>Browser: read smaller clientWidth + Engine->>Engine: occupiedSize now too large + loop hide until fit + Engine->>Engine: dequeue worst visible item + Engine->>Wrapper: onUpdateItemVisibility(false) + end + Engine-->>Wrapper: onUpdateOverflow + Wrapper->>Browser: CSS hides new data-overflowing items +``` + +Functionally: + +- no registration churn is needed +- only the container size changed +- visible items hide one by one in queue order until the toolbar fits again + +Cost profile: + +- JS: moderate when only a few items cross the threshold +- layout: usually less risky than direct DOM mutation because the update comes after a resize observation +- paint: each newly hidden item can trigger visible repaint of the toolbar region + +This is the path where the current implementation is usually acceptable in practice. + +### Scenario 4: Container grows and hidden items return + +Growth is not just the reverse of shrink. The overflow menu threshold makes it slightly asymmetric. + +```mermaid +flowchart TD + A[Container width increases] --> B[Read availableSize] + B --> C{Any hidden items?} + C -- no --> D[Nothing to do] + C -- yes --> E[Try to show best hidden item] + E --> F[Recompute occupiedSize including possible menu removal] + F --> G{Still fits?} + G -- yes --> H[Keep item visible] + G -- no --> I[Hide item again or stop] + H --> J{More hidden items?} + J -- yes --> E + J -- no --> K[Dispatch stable state] + I --> K +``` + +Functionally: + +- the show loop promotes the best hidden item back to visible +- the algorithm has a special case for the last hidden item because removing the overflow menu may free enough space to make that last item fit + +Cost profile: + +- JS: moderate if many items become visible again +- layout: similar measurement pattern to shrinking +- paint: repaint of newly shown items and possible menu disappearance + +Interesting nuance: + +- the last hidden item is worth an extra attempt because one visible item can replace both itself and the menu footprint + +### Scenario 5: Add a new low-priority item while already full + +This is common in dynamic toolbars where commands appear conditionally. + +```mermaid +flowchart TD + A[New item registered] --> B[Item inserted into visible queue] + B --> C[forceDispatch set true] + C --> D[Debounced update] + D --> E[Read sizes] + E --> F{Does new item fit?} + F -- yes --> G[State unchanged except item visible] + F -- no --> H[Hide lowest-ranked visible item] + H --> I{Is it the new item?} + I -- yes --> J[New item immediately overflows] + I -- no --> K[Existing lower-ranked item overflows] +``` + +Functionally: + +- new items start visible by default +- if the toolbar was already near capacity, the new item often overflows immediately on the next pass +- `forceDispatch` ensures consumers hear about the change even if queue tops happen to look similar + +Cost profile: + +- JS: low to moderate +- layout: one pass of measurements +- paint: only if the stable set changes + +Interesting nuance: + +- the double-round processing exists partly to stabilize cases like this, where a newly inserted visible item disturbs the optimal split + +### Scenario 6: Add a new high-priority item that should displace a visible low-priority item + +This is where the priority repair step matters. + +```mermaid +flowchart TD + A[High-priority item added] --> B[Temporarily visible] + B --> C[processOverflowItems] + C --> D{Best hidden outranks worst visible?} + D -- yes --> E[Hide worst visible item] + E --> F[Move better item toward visibility] + F --> G[Run normal show/hide stabilization] + G --> H[Dispatch reordered visibility split] +``` + +Functionally: + +- a new important command can bump out a weaker visible command even if the total visible count stays the same +- this is not a pure width problem; it is a width-plus-priority rebalance + +Cost profile: + +- JS: slightly higher than the low-priority add case because the repair loop may run before normal fit logic +- layout: same general measurement footprint +- paint: one item may disappear while another appears + +This is a good example of why the engine uses queues rather than a simple left-to-right cutoff. + +### Scenario 7: Remove a visible item from a full toolbar + +This is a relatively favorable case. + +```mermaid +sequenceDiagram + participant App as App code + participant Engine as Overflow manager + participant Browser as Browser + + App->>Engine: removeItem(id) + Engine->>Engine: remove from queues and registry + Engine->>Engine: set forceDispatch true + Engine->>Engine: debounced update + Engine->>Browser: read sizes + Engine->>Engine: show best hidden item if space allows + Engine-->>App: dispatch updated visibility +``` + +Functionally: + +- removing a visible item may free enough room for one hidden item to return +- because `remove()` on the priority queue uses `indexOf`, item removal has a linear queue-search component before heap repair + +Cost profile: + +- JS: moderate for large `n` because removal is $O(n)$ in the queue implementation +- layout: one rebalance pass +- paint: modest; often one item appears and one disappears, or only one appears + +### Scenario 8: Grouped items partially overflow + +This is the most interesting extension feature. + +```mermaid +stateDiagram-v2 + [*] --> visible + visible --> overflow: some items hidden, some visible + overflow --> hidden: last visible item hidden + hidden --> overflow: first hidden item shown + overflow --> visible: all items shown again +``` + +Functionally: + +- each item move updates two sets inside the group manager +- a group enters `overflow` when at least one item is visible and at least one is hidden +- a divider stays visible while the group is `visible` or `overflow` +- the divider hides when the last visible item in that group disappears + +Cost profile: + +- JS: base model cost plus set bookkeeping +- layout: divider size may participate in `occupiedSize()` +- paint: divider visibility toggles can repaint even when no new command becomes visible + +Interesting nuance: + +- grouping changes semantics more than it changes raw cost +- the main additional cost comes from divider accounting inside every `occupiedSize()` call + +### Scenario 9: Entire group disappears + +This is a special grouped case worth separating because the divider behavior changes sharply. + +```mermaid +flowchart TD + A[Only one visible item remains in group] --> B[Hide that item] + B --> C[Group visibleItemIds becomes empty] + C --> D[Group state becomes hidden] + D --> E[Divider gets data-overflowing] + E --> F[occupiedSize shrinks by item plus divider] +``` + +Functionally: + +- hiding the last visible item has a two-part footprint change: the item goes away and the divider goes away too +- that means groups can create slightly discontinuous layout changes compared with independent items + +Cost profile: + +- JS: low incremental overhead +- layout: the divider disappearing can help the next item fit earlier than expected +- paint: visible because both command and divider styling change + +### Scenario 10: Pinned items consume too much width + +This is the clearest example of a case where the engine cannot solve the physical constraint. + +```mermaid +flowchart TD + A[occupiedSize > availableSize] --> B[Need to hide visible items] + B --> C[Peek worst visible item] + C --> D{Pinned?} + D -- yes --> E[Stop hide loop] + D -- no --> F[Hide item and continue] + E --> G[Dispatch still-overflowed stable state] +``` + +Functionally: + +- pinned items act like an unshrinkable frontier +- if the next hide candidate is pinned, the algorithm stops, even if the container still does not fit +- the result can be a visually overfull container or clipped layout depending on surrounding CSS + +Cost profile: + +- JS: very low incremental pinning overhead +- layout: unchanged from base model until the stop condition is reached +- paint: depends on how much overflow remains visible in the page layout + +Important design implication: + +- pinning is cheap computationally but can make the UI impossible to satisfy geometrically + +### Scenario 11: `minimumVisible` prevents full convergence + +This is similar to pinning, but the hard stop is a count rather than item identity. + +```mermaid +flowchart TD + A[occupiedSize > availableSize] --> B[Hide items while visible count > minimumVisible] + B --> C{visible count == minimumVisible?} + C -- no --> D[Continue hiding] + C -- yes --> E[Stop even if still too wide] +``` + +Functionally: + +- the engine respects the configured minimum visible count +- it may stop before true geometric fit is reached + +Cost profile: + +- JS: negligible feature overhead +- layout: same as the base model up to the stop point +- paint: same as ordinary hiding + +### Scenario 12: Overflow menu with `hasHiddenItems = true` + +This case matters when hidden items exist outside the engine's own item registry. + +```mermaid +flowchart TD + A[hasHiddenItems true] --> B[occupiedSize always includes menu when menu exists] + B --> C[Less space available for visible registered items] + C --> D[Earlier overflow threshold] +``` + +Functionally: + +- the menu is charged against occupied size even if the engine itself currently has no invisible registered items +- this creates a more conservative fit line + +Cost profile: + +- JS: negligible +- layout: one extra measured element in passes where the menu exists +- paint: no special extra cost beyond menu visibility itself + +### Scenario 13: Burst of registration changes in one tick + +This is the best-case scenario for the debounce design. + +```mermaid +sequenceDiagram + participant App as App code + participant Engine as Overflow manager + participant Microtask as Microtask queue + + App->>Engine: addItem(A) + App->>Engine: addItem(B) + App->>Engine: removeItem(C) + App->>Engine: update() + Engine->>Microtask: schedule one debounced run + Note over Engine,Microtask: repeated update() calls in same tick collapse + Microtask-->>Engine: run forceUpdate once +``` + +Functionally: + +- multiple mutations collapse into one processing pass +- this avoids repeated layout work and repeated callback churn inside the same macrotask + +Cost profile: + +- JS: much better than processing each mutation separately +- layout: one pass instead of many +- paint: one consolidated visible-state update + +This is one of the strongest design choices in the current engine. + +### Scenario 14: Worst-case thrash on a large toolbar + +This is the pathological case to keep in mind when estimating upper bounds. + +```mermaid +flowchart TD + A[Many items, dirty layout, width change] --> B[Measure container and all visible items] + B --> C[Hide one item] + C --> D[Recompute occupiedSize over many visible items] + D --> E[Hide another item] + E --> F[Repeat many times] + F --> G[Attribute writes trigger style and layout invalidation] + G --> H[Dispatch after many queue moves] +``` + +Functionally: + +- many items cross the threshold in a single pass +- the engine repeatedly recomputes aggregate occupied size after each move +- style invalidation from `display: none` interacts with geometry reads + +Cost profile: + +- JS: the closest thing to the documented worst case +- layout: highest risk of forced reflow if layout was dirty before the reads +- paint: potentially substantial because many items disappear or reappear +- composite: still secondary to layout and paint + +This is where the rough $O(n^2)$ behavior becomes noticeable. + +## Scenario comparison + +| Scenario | Engine behavior | JS cost | Layout risk | Paint impact | Notes | +| --------------------------- | ----------------------------- | ----------------- | ----------------- | ------------------- | ------------------------------------- | +| Initial mount, all fit | one pass, no hides | low | low to medium | low | measures all once | +| Initial mount, overflow | repeated hides | medium to high | medium | medium | often most expensive mount path | +| Container shrink | hide until fit | medium | medium | medium | typical toolbar case | +| Container grow | show until fit | medium | medium | medium | menu threshold makes it asymmetric | +| Add low-priority item | likely self-overflows | low to medium | low to medium | low to medium | stabilized by second round | +| Add high-priority item | displace weaker visible item | medium | low to medium | medium | uses priority repair loop | +| Remove visible item | maybe reveal one hidden item | medium | low to medium | low to medium | queue remove is $O(n)$ | +| Partial grouped overflow | update group sets and divider | medium | medium | medium | semantic complexity more than raw CPU | +| Pinned frontier reached | stop hiding early | low | medium | low to medium | may stay geometrically overflowed | +| `minimumVisible` reached | stop hiding early | low | medium | low to medium | also may stay overflowed | +| Burst mutations in one tick | collapse to one pass | low relative cost | low relative risk | low relative impact | best-case scheduling behavior | +| Worst-case thrash | many repeated size scans | high | high | high | main pathological upper bound | + +## How to read these scenarios in DevTools + +If you profile the engine in browser tooling, expect to see these signatures: + +- `ResizeObserver` callback or microtask scheduling before the main update work +- scripting time around queue churn and callback dispatch +- layout work clustered around `clientWidth`, `offsetWidth`, `clientHeight`, and `offsetHeight` reads +- style and paint work after attribute changes that lead to `display: none` + +Rough interpretation guide: + +- mostly scripting time: many queue moves or large visible-item scans +- mostly layout time: geometry reads are hitting dirty layout +- mostly paint time: many items or dividers are changing visibility in a visually rich toolbar + +## Practical ranking of feature cost + +If you need a simple ordering from most impactful to least impactful, this is the right mental model: + +1. Base repeated measurement and rebalance loop +2. Number of items that cross visibility threshold in a pass +3. Group dividers and group bookkeeping +4. Overflow menu threshold effects +5. Queue removal during churn +6. Pinning and `minimumVisible` guards + +The important takeaway is that grouping and pinning are not the main reason the system costs what it costs. The dominant factor is still repeated aggregate measurement combined with layout-affecting visibility changes. + +## Worked example + +This section walks through one concrete pass with numbers. The point is not that every toolbar looks like this, but that the current algorithm becomes much easier to reason about when you can watch the visible and invisible sets evolve. + +### Setup + +Assume a horizontal container with: + +- `container.clientWidth = 420` +- `padding = 10` +- `availableSize = 410` +- overflow menu width `M = 40` +- no groups +- no pinned items +- `overflowDirection = 'end'` +- lower priority hides first + +Items in DOM order: + +| Item | Width | Priority | +| ---- | ----: | -------: | +| A | 90 | 100 | +| B | 80 | 80 | +| C | 70 | 60 | +| D | 65 | 40 | +| E | 60 | 20 | + +Total width if all are visible: + +$$ +90 + 80 + 70 + 65 + 60 = 365 +$$ + +In this first state, all items fit because: + +$$ +365 \le 410 +$$ + +So the stable state is: + +- visible: `A B C D E` +- invisible: none +- overflow menu not counted + +### Now shrink the container + +Assume the container shrinks to: + +- `container.clientWidth = 280` +- `padding = 10` +- `availableSize = 270` + +If all items remain visible, occupied size is still 365, so the engine must hide items. + +### Pass trace + +#### Step 1: Evaluate current size + +$$ +occupied = 365 +$$ + +Since $365 > 270$, the hide loop starts. + +The lowest-ranked visible item is `E`, so `E` moves to the invisible queue. + +State: + +- visible: `A B C D` +- invisible: `E` + +Now the overflow menu becomes active because there is at least one hidden item. + +New occupied size: + +$$ +90 + 80 + 70 + 65 + 40 = 345 +$$ + +The menu matters here. Hiding a 60px item only reduced occupied size by 20px because the 40px menu appeared. + +#### Step 2: Still too wide + +Since $345 > 270$, hide the next lowest-ranked visible item: `D`. + +State: + +- visible: `A B C` +- invisible: `D E` + +Occupied size: + +$$ +90 + 80 + 70 + 40 = 280 +$$ + +Still too wide. + +#### Step 3: Hide one more item + +Hide `C`. + +State: + +- visible: `A B` +- invisible: `C D E` + +Occupied size: + +$$ +90 + 80 + 40 = 210 +$$ + +Now: + +$$ +210 \le 270 +$$ + +So the hide loop can stop. + +### Show-loop sanity pass + +The engine runs two rounds of show/hide stabilization. After landing on `A B` visible, it tries to see whether the best hidden item can come back. + +The best hidden item is `C` with width 70. + +If `C` is shown again, occupied size becomes: + +$$ +90 + 80 + 70 + 40 = 280 +$$ + +That does not fit, so `C` stays hidden. + +Final stable state: + +- visible: `A B` +- invisible: `C D E` +- overflow menu visible + +### Trace diagram + +```mermaid +flowchart TD + S0[Visible A B C D E / Occupied 365] --> S1[Hide E] + S1 --> S2[Visible A B C D + Menu / Occupied 345] + S2 --> S3[Hide D] + S3 --> S4[Visible A B C + Menu / Occupied 280] + S4 --> S5[Hide C] + S5 --> S6[Visible A B + Menu / Occupied 210] + S6 --> S7[Try show C] + S7 --> S8[C does not fit at 280] + S8 --> S9[Stable: A B visible, C D E hidden] +``` + +### What this example teaches + +- the menu creates a threshold discontinuity +- the number of hidden items is not the same as the amount of freed space +- repeated aggregate size recomputation is easy to see even in a five-item example + +### Grouped variant of the same example + +Now add one divider before `D` and put `D` and `E` in the same group. + +Assume: + +- divider width `G = 12` +- group is visible while either `D` or `E` remains visible + +Initial occupied size becomes: + +$$ +365 + 12 = 377 +$$ + +When only `E` hides, occupied size becomes: + +$$ +90 + 80 + 70 + 65 + 12 + 40 = 357 +$$ + +The divider remains because `D` is still visible. + +When `D` also hides, the group becomes fully hidden and the divider disappears too. Occupied size becomes: + +$$ +90 + 80 + 70 + 40 = 280 +$$ + +That is a 77px drop from the previous step: 65px for `D` plus 12px for the divider. + +This is why grouped layouts can feel slightly more discontinuous even though the bookkeeping cost is not dramatic. + +### Pinned variant of the same example + +Now assume: + +- `A` is pinned +- `B` is pinned +- container shrinks further so `availableSize = 180` + +The engine can hide `C`, `D`, and `E`, leaving: + +$$ +90 + 80 + 40 = 210 +$$ + +That still exceeds 180, but the next hide candidate would be pinned. The loop stops there. + +Stable state becomes: + +- visible: `A B` +- invisible: `C D E` +- occupied: 210 +- available: 180 + +So the engine converges logically, but not geometrically. + +## Redesign and optimization directions + +If the goal is to understand how complex the current engine is versus how complex an alternative would be, these are the main redesign directions worth considering. + +### Option 1: Keep the current model and optimize constants + +This is the smallest-change path. + +Potential improvements: + +- keep a running occupied-size total instead of recomputing it from scratch on every show/hide step +- replace queue `remove()` with an indexed heap or a heap-plus-map design to avoid the current $O(n)$ search +- cache divider contribution more explicitly instead of scanning all groups in every `occupiedSize()` call + +Expected impact: + +- lower JS time +- lower pressure from repeated aggregate iteration +- same overall behavioral model +- same layout/paint characteristics because visibility still toggles with `display: none` + +Tradeoff: + +- moderate implementation complexity increase +- low API risk + +### Option 2: Incremental occupied-size accounting + +This is the most obvious algorithmic upgrade. + +Instead of computing: + +$$ +occupied = \sum visibleItems + \sum visibleDividers + menu +$$ + +from scratch after each move, keep a mutable running total. + +For example: + +- when hiding an item, subtract its width +- if that was the first hidden item, add menu width +- if a group becomes hidden, subtract divider width +- when showing an item, add its width +- if that was the last hidden item, subtract menu width + +Expected impact: + +- per-move work drops closer to heap operations plus a few constant-time updates +- worst-case JS behavior improves materially + +Tradeoff: + +- correctness becomes trickier because menu and divider transitions are threshold-based +- subtle bugs become easier to introduce than in the current recompute-from-scratch approach + +### Option 3: Prefix sums over a pre-sorted order + +This works best when the problem can be reduced to a stable total ordering of items. + +Idea: + +1. sort visible candidates once by hide priority +2. build cumulative widths +3. find the largest visible prefix that fits + +If the ordering is static enough, you can determine the split with something closer to binary search rather than repeated one-item-at-a-time moves. + +Expected impact: + +- better asymptotic behavior for large `n` +- easier reasoning about fit thresholds in simple non-grouped cases + +Tradeoff: + +- groups, pinned items, menu thresholds, and DOM-order tie-breaking complicate the prefix model +- dynamic insertion and removal mean the sorted structure must be maintained incrementally +- the implementation starts to look more like a custom ordered index rather than a simple manager + +### Option 4: Separate measurement from decision more aggressively + +The current engine interleaves: + +- reads of element sizes +- decision logic +- attribute writes via callbacks + +A more explicitly phased design could do: + +1. measurement phase +2. pure decision phase +3. commit phase + +Expected impact: + +- easier profiling and reasoning +- easier to test because the decision step can be fed with synthetic widths +- possible reduction in layout thrash if commits are carefully batched after all reads + +Tradeoff: + +- more architecture, not necessarily lower total cost by itself +- still needs a better fit algorithm if JS complexity is the real bottleneck + +### Option 5: Transform the rendering strategy instead of only the algorithm + +This changes browser-pipeline cost more than algorithmic cost. + +For example, instead of using `display: none` immediately, a system could: + +- keep items in layout but visually collapse them differently +- render the overflow list in a separate layer +- avoid repeated attribute-driven selector invalidation + +Expected impact: + +- maybe lower paint or composite cost in some designs +- maybe smoother transitions + +Tradeoff: + +- much more rendering complexity +- likely worse accessibility and semantics if not done carefully +- often not worth it for toolbar-scale components + +### Which optimizations are most credible here? + +For this specific engine, the most credible improvements are: + +1. incremental occupied-size tracking +2. indexed heap removal +3. clearer read/decide/write phasing + +Those would attack the actual hot paths without changing the external mental model too much. + +### What would not help much? + +These ideas are less likely to move the needle significantly: + +- micro-optimizing pinning checks +- micro-optimizing group set writes +- rewriting the debounce logic + +Those are not where the main cost sits. The dominant cost remains repeated size aggregation and layout-affecting visibility changes. + +## How to reason about redesign cost + +If you are comparing implementations, this is the right split: + +- current engine complexity: low to medium conceptual complexity, medium runtime cost under churn +- incremental-total engine: medium conceptual complexity, lower JS runtime cost, slightly higher correctness risk +- fully indexed ordered model: high conceptual complexity, potentially much better large-`n` behavior, much higher maintenance cost + +In other words, the current design is simple enough to be robust, but it knowingly pays for that simplicity with repeated aggregate work. + +## Takeaways + +- The base engine is conceptually simple but not constant-time: it is a queue-based rebalance loop with repeated aggregate measurement. +- Grouping is a small feature tax on top of the base model, mainly from extra set bookkeeping and divider accounting. +- Pinning is almost free computationally. +- The dominant real-world cost is geometry reads and layout invalidation from hiding/showing items, not the queue operations themselves. +- Worst-case rebalances can trend toward quadratic JS work because aggregate occupied size is recomputed from scratch after each move. + +## Relevant source files + +- `packages/react-components/priority-overflow/src/overflowManager.ts` +- `packages/react-components/priority-overflow/src/priorityQueue.ts` +- `packages/react-components/priority-overflow/src/createResizeObserver.ts` +- `packages/react-components/priority-overflow/src/debounce.ts` +- `packages/react-components/priority-overflow/src/types.ts` diff --git a/packages/react-components/react-overflow/library/docs/overflow-northstar.md b/packages/react-components/react-overflow/library/docs/overflow-northstar.md new file mode 100644 index 00000000000000..226561d6bf2293 --- /dev/null +++ b/packages/react-components/react-overflow/library/docs/overflow-northstar.md @@ -0,0 +1,404 @@ +# Overflow Northstar + +This document is the temporary working document for future overflow changes. + +It is not the source of truth for current behavior. Current behavior is described by: + +- `overflow-algorithm.md` for the engine +- `react-overflow-react-bridge.md` for the React bridge + +This file exists to capture: + +- the current architecture decisions +- the desired future state +- the remaining narrow decisions +- the basis for implementation discussions + +The long-term goal is to delete this document once its useful conclusions have been absorbed into the two specs. + +## Why this work exists + +This document is not proposing refactoring for its own sake. + +It exists because the current engine and bridge specs already point to concrete problems that the future design should address. + +### What the engine spec tells us + +The engine spec shows that the current engine: + +- couples configuration to observation through `observe(container, options)` +- recomputes aggregate occupied size repeatedly during stabilization +- exposes its results primarily through callback flow instead of stable readable state +- treats registration as add/remove lifecycle rather than as a long-lived controller relationship + +Those are not speculative complaints. They are direct consequences of the current engine contract and algorithm shape described in `overflow-algorithm.md`. + +### What the bridge spec tells us + +The bridge spec shows that the current bridge: + +- mirrors engine state into React-owned state +- rebuilds visibility maps on every engine update +- republishes those maps through context +- recreates the engine when options change after first mount +- sometimes causes follow-up engine work through React effects, especially around menu handling + +Those are not aesthetic concerns. They are the concrete bridge costs and ownership problems described in `react-overflow-react-bridge.md`. + +### What the northstar is trying to solve + +The northstar is therefore trying to solve three things that are already documented in the current specs: + +1. the engine contract is not stable enough for the bridge to hold onto comfortably +2. the bridge is compensating for engine contract weakness by re-owning and republishing engine state +3. React integration is paying more churn than it should because ownership and lifecycle boundaries are blurry + +That is why the desired state in this document is centered on: + +- a long-lived, incrementally configurable, readable, and subscribable `OverflowManager` +- a bridge that remains the stable React boundary without re-owning canonical overflow state +- manager-in-context plus selector-based bridge subscriptions + +### Success condition + +This work is only justified if it removes or materially reduces the specific problems already described in the current specs. + +If it does not improve those concrete problems, then it is not a worthwhile migration or refactor. + +## Current conclusions + +The current research baseline leads to these conclusions: + +1. the engine has real cost, especially repeated aggregate measurement and layout-affecting visibility changes +2. the React bridge has real cost, especially mirrored state, map rebuilding, and React subscription churn +3. the bridge should already be the stable boundary for React, but the current engine API may not give it a strong enough contract to play that role well +4. future work should start from clearly stated ownership, subscription, lifecycle, and measurement questions rather than from broad dissatisfaction + +## Decision log + +### 1. Keep the three-document structure + +The current document structure is intentionally: + +- `overflow-algorithm.md` for the engine spec +- `react-overflow-react-bridge.md` for the bridge spec +- `overflow-northstar.md` for everything exploratory and temporary + +### 2. Keep the bridge as the intended stable React boundary + +The current framing is not that React necessarily needs a new permanent adapter layer. + +The current framing is that the existing bridge is supposed to be that stable boundary already, and the engine contract may be underdesigned for that role. + +### 3. Evolve `OverflowManager` rather than invent a new top-level abstraction name + +The target is a future shape of `OverflowManager`, not a separately named controller. + +### 4. Store-like behavior is acceptable in the engine if it remains generic + +Readable state and generic subscription are acceptable engine concerns as long as they remain host-agnostic and do not make the engine React-shaped. + +### 5. Converge on the viable engine contract first + +The current target is the viable future `OverflowManager`, not the ideal one. + +The viable target includes: + +- `setOptions()` +- `setContainer()` +- `setOverflowMenu()` +- existing add/remove registration for now +- `getSnapshot()` +- `subscribe()` +- `destroy()` + +### 6. Handle-based registration is ideal, but not required yet + +`registerItem(...)->handle.update()/unregister()` and its divider equivalent remain part of the ideal engine surface, but they are not required for the first viable improved state. + +### 7. Engine contract work comes before deeper bridge redesign + +The next phase should assume that improving the engine contract comes first. + +Only after that should we decide how much of the remaining bridge complexity still justifies deeper bridge redesign or ideal-surface expansion. + +### 8. The viable transition should be non-breaking at the component level + +The viable engine and bridge redesign should be treated as non-breaking at the component level. + +Breaking changes should only enter the conversation later if the team intentionally chooses to redesign the public React ergonomics, not as a side effect of fixing the engine contract. + +## Desired state + +This section captures the current end-state target as a single coherent model. + +### 1. Engine + +The desired engine state is: + +- `OverflowManager` remains the core engine abstraction +- it is long-lived rather than routinely reconstructed +- it is incrementally configurable +- it exposes canonical readable overflow state +- it exposes generic subscription semantics +- it stays host-agnostic and non-React-specific + +### Viable future `OverflowManager` + +The current convergence target is: + +```ts +interface OverflowManager { + setOptions(options: Partial): void; + setContainer(element: HTMLElement | null): void; + setOverflowMenu(element: HTMLElement | null): void; + + addItem(item: OverflowItemEntry): void; + removeItem(itemId: string): void; + addDivider(divider: OverflowDividerEntry): void; + removeDivider(groupId: string): void; + + update(): void; + forceUpdate(): void; + + getSnapshot(): OverflowSnapshot; + subscribe(listener: () => void): () => void; + + destroy(): void; +} +``` + +This is the smallest contract that materially changes the current bridge behavior rather than merely making the engine look cleaner. + +### Ideal future `OverflowManager` + +The ideal version may later add: + +```ts +registerItem(item: OverflowItemEntry): OverflowItemHandle; +registerDivider(divider: OverflowDividerEntry): OverflowDividerHandle; +``` + +where the returned handles support `update()` and `unregister()`. + +This remains a second-order improvement, not part of the immediate target. + +### 2. Bridge-facing snapshot + +The bridge-facing snapshot should be normalized and React-friendly. + +```ts +type OverflowSnapshot = { + hasOverflow: boolean; + overflowCount: number; + itemVisibility: Record; + groupVisibility: Record; +}; +``` + +Current recommendation: + +- `hasOverflow` is useful and cheap +- `overflowCount` should be first-class because the engine already knows invisible item count +- `itemVisibility` and `groupVisibility` are the most stable bridge-facing structures + +By default, raw `visibleItems` and `invisibleItems` should not be treated as the bridge-facing canonical snapshot shape. + +### 3. Bridge ownership model + +The desired bridge state is: + +- the bridge remains the stable React boundary +- the bridge does not own canonical overflow state +- the bridge adapts engine-owned state into React usage patterns +- the bridge stops compensating for engine lifecycle weakness + +In other words: + +- engine owns overflow truth +- bridge owns React integration and ergonomics + +### 4. `useOverflowContainer` + +The desired `useOverflowContainer` shape is: + +- one long-lived manager instance per overflow container +- `setOptions()` used for configuration changes +- `setContainer()` used for container attachment changes +- `setOverflowMenu()` used for menu attachment changes +- registration helpers exposed for item and divider wiring +- manager returned explicitly for downstream bridge usage + +A likely internal bridge-facing return shape is: + +```ts +type UseOverflowContainerReturn = { + containerRef: React.RefObject; + manager: OverflowManager; + registerItem(item: OverflowItemEntry): () => void; + registerDivider(divider: OverflowDividerEntry): () => void; + registerOverflowMenu(element: HTMLElement): () => void; + updateOverflow(): void; +}; +``` + +This keeps current registration ergonomics while making manager ownership explicit. + +### 5. `Overflow.tsx` + +The desired `Overflow.tsx` state is: + +- thin wrapper around manager lifecycle and context wiring +- no primary mirrored overflow state ownership +- still exposes `onOverflowChange` +- still composes refs and classes +- still provides React-friendly composition surface + +So `Overflow.tsx` should remain important, but it should stop behaving like a second canonical state owner. + +### 6. Context + +The desired context state is: + +- manager-in-context, not snapshot-in-context as the primary model +- registration helpers remain in context for migration-friendly ergonomics + +The target shape is: + +```ts +type OverflowContextValue = { + manager: OverflowManager; + registerItem(item: OverflowItemEntry): () => void; + registerDivider(divider: OverflowDividerEntry): () => void; + registerOverflowMenu(element: HTMLElement): () => void; + updateOverflow(): void; +}; +``` + +This preserves the current practical API shape while keeping ownership explicit. + +### 7. Subscription helper layer + +The bridge should have one small internal selector-based subscription helper. + +Conceptually: + +```ts +function useOverflowSelector(selector: (snapshot: OverflowSnapshot) => T): T; +``` + +Its job is to: + +1. obtain the manager from context +2. subscribe to the manager +3. read `getSnapshot()` +4. apply the selector +5. let React re-render when the selected value changes + +Current recommendation on equality: + +- keep selector results narrow enough that `Object.is`-style stability is usually sufficient +- avoid a broad custom equality layer unless profiling proves it is necessary + +### 8. State-reading hooks + +The desired hook model is: + +- `useOverflowVisibility()` selects full visibility structures from the manager snapshot +- `useIsOverflowItemVisible(id)` selects one item visibility flag +- `useIsOverflowGroupVisible(id)` selects one group visibility value +- `useOverflowCount()` reads a first-class `overflowCount` from the manager snapshot + +These hooks should stop reading bridge-owned mirrored context state. + +### 9. Registration hooks and components + +The desired registration model in the viable state is: + +- public component APIs stay stable +- registration helpers continue to exist for bridge ergonomics +- internal registration still uses add/remove semantics first + +If later evidence shows metadata churn is still a first-order problem, then handle-based registration can be added as the next engine step. + +### 10. Public API stance + +The desired near-term public API stance is: + +- no required component-level breaking changes +- `Overflow`, `OverflowItem`, and `OverflowDivider` remain stable +- current props remain stable +- current children-based composition remains stable + +Breaking changes are only justified later if the team intentionally decides to redesign public ergonomics, not as a side effect of fixing engine and bridge ownership. + +## What improves immediately with the viable engine contract + +If the viable future `OverflowManager` existed, these bridge problems improve immediately: + +1. manager recreation on option changes +2. attachment and configuration being entangled +3. callback-only engine state access +4. the bridge being forced to act as the only stable state publisher +5. menu attachment awkwardness as a lifecycle concern + +## What still remains bridge-owned after that + +Even under the viable engine contract, these still remain bridge-side questions: + +- final React subscription granularity +- context propagation strategy +- whether broad snapshot reads are acceptable +- whether menu behavior still needs any secondary bridge modeling +- whether a future hook-first public API is worth pursuing + +These are now secondary questions rather than architectural blockers. + +## Remaining narrow decisions + +At this point, the remaining questions are narrow and implementation-shaped. + +### 1. Do we need custom selector equality? + +Current recommendation: + +- not by default +- keep selections narrow first +- only add broader equality support if profiling proves it necessary + +### 2. Should raw `visibleItems` / `invisibleItems` remain available anywhere? + +Current recommendation: + +- not as the bridge-facing canonical snapshot shape +- they may still exist as internal engine data or secondary outputs if later needed + +### 3. Does ideal handle-based registration still matter? + +Current recommendation: + +- yes, but explicitly second-order +- only pursue it if metadata churn remains a meaningful bridge cost after the viable engine contract is in place + +## Final current conclusion + +The current desired state is: + +1. a long-lived, incrementally configurable, readable, and subscribable `OverflowManager` +2. a normalized bridge-facing snapshot containing `hasOverflow`, `overflowCount`, `itemVisibility`, and `groupVisibility` +3. a bridge that remains the stable React boundary without re-owning canonical overflow state +4. `useOverflowContainer` as the stable owner of one manager instance +5. manager-in-context with registration helpers +6. a small bridge subscription helper layer +7. state-reading hooks that subscribe/select from manager-owned state +8. no required public component-level breaking changes for the viable transition + +This is the current basis for implementation discussions. + +## Exit condition + +This document should shrink over time. + +When a conclusion becomes stable enough to be treated as truth, it should move into either the engine spec or the bridge spec. + +The success condition is that this document eventually disappears because the two specs are good enough to stand on their own. diff --git a/packages/react-components/react-overflow/library/docs/react-overflow-react-bridge.md b/packages/react-components/react-overflow/library/docs/react-overflow-react-bridge.md new file mode 100644 index 00000000000000..b4eb27782f2651 --- /dev/null +++ b/packages/react-components/react-overflow/library/docs/react-overflow-react-bridge.md @@ -0,0 +1,264 @@ +# React Overflow Bridge Spec + +This document is the working specification for the bridge between the `@fluentui/priority-overflow` engine and the React package in `@fluentui/react-overflow`. + +The key point is that the engine and the React wrapper have different strengths: + +- the engine is imperative, DOM-oriented, and measurement-driven +- the React layer is declarative, context-driven, and render-oriented + +That mismatch is exactly where some of the current awkwardness and extra cost come from. + +## Scope + +- React wrapper package: `packages/react-components/react-overflow/library/src/` +- Main wrapper component: `components/Overflow.tsx` +- Registration hooks: `useOverflowItem.ts`, `useOverflowMenu.ts`, `useOverflowDivider.ts` +- Context layer: `overflowContext.ts` + +## One-sentence model + +The React package turns an imperative DOM overflow manager into a React-facing API by registering DOM nodes through refs, mirroring engine state into React state, and redistributing that state through context selectors. + +## Why the bridge is not ideal + +The bridge works, but it has structural costs. + +The main ones are: + +1. it duplicates state that already exists inside the engine +2. it converts imperative updates into React re-renders +3. it sometimes triggers follow-up engine work in response to React state changes +4. it relies on child-cloning and ref plumbing that constrain the API surface + +This document is intentionally descriptive. Its job is to explain the current bridge clearly enough that later design discussions can start from a shared understanding instead of from vague dissatisfaction. + +## Data flow + +```mermaid +flowchart TD + A[React renders Overflow tree] --> B[Refs register DOM nodes with engine] + B --> C[Engine measures and computes visibility] + C --> D[Engine writes data-overflowing attributes] + C --> E[Engine emits visibleItems and invisibleItems] + E --> F[Overflow.tsx rebuilds itemVisibility and groupVisibility maps] + F --> G[React setState] + G --> H[Context provider value changes] + H --> I[Selector hooks re-render interested components] + I --> J[Some hooks trigger follow-up engine update calls] +``` + +## Current bridge architecture + +### 1. DOM-first registration via refs + +`OverflowItem` and related hooks register items in `useIsomorphicLayoutEffect` after the DOM node exists. + +That means the engine lifecycle depends on: + +- React committing DOM nodes +- refs resolving correctly +- layout effects running in order + +This is pragmatic, but it means the bridge is not a pure data model. It is tightly coupled to commit timing. + +### 2. State mirroring in `Overflow.tsx` + +The engine emits arrays of visible and invisible items. The wrapper then rebuilds React-friendly maps: + +- `itemVisibility: Record` +- `groupVisibility: Record` +- `hasOverflow: boolean` + +This is a second state system layered over the engine's own state. + +That has two consequences: + +- extra JS work on every engine update +- extra React invalidation even when consumers only need a small slice + +### 3. Context redistribution + +The wrapper pushes state and registration functions through `OverflowContext`. + +Using `react-context-selector` helps, but it does not eliminate all cost. The provider value still changes when: + +- `itemVisibility` is rebuilt +- `groupVisibility` changes +- `hasOverflow` changes + +So the bridge still turns engine updates into React subscription activity. + +### 4. Two visibility channels + +There are really two visibility channels in the system: + +- DOM attributes written by the engine callback path +- React state exposed through hooks + +That duplication is useful for ergonomics, but it means the wrapper is not just a thin type-safe façade. It is a synchronizing layer. + +## Concrete pain points + +### Pain point 1: full map rebuild on every overflow update + +In `Overflow.tsx`, each `onUpdateOverflow` callback rebuilds a fresh `itemVisibility` object from the visible and invisible arrays. + +That means: + +- every update allocates a new map +- every update touches all items that changed set membership +- even consumers looking up a single item still depend on a rebuilt parent object + +This cost is separate from the engine's own work. + +### Pain point 2: React re-renders are coupled to engine churn + +The hook `useOverflowVisibility()` already warns that it re-renders for every overflow visibility change. + +That is a real signal that the bridge is not especially cheap for broad subscriptions. + +If an app reads the full visibility map, React work scales with every overflow event, even if the DOM attributes alone would have been enough for hiding. + +### Pain point 3: menu state can cause follow-up engine work + +`useOverflowMenu()` derives `isOverflowing` from `overflowCount`, and when that becomes true it calls `updateOverflow()` again in a layout effect. + +This is understandable because the menu itself changes occupied size, but architecturally it means: + +- engine update +- React state update +- React effect +- follow-up engine update + +So the bridge can create a second phase of work around menu activation. + +### Pain point 4: option changes recreate the engine instance + +`useOverflowContainer()` recreates the overflow manager when observed options change after first mount. + +That keeps behavior simple, but it means option changes are not incremental. They effectively reset the imperative engine layer. + +This is acceptable for infrequent prop changes, but it is not a particularly elegant bridge if options are dynamic. + +### Pain point 5: trigger-style child cloning is restrictive + +`Overflow` and `OverflowItem` use trigger-style helpers to clone children and merge refs. + +That introduces API constraints: + +- the child must be ref-compatible +- the wrapper must successfully find and merge the target element ref +- composition is less direct than a hook-only API bound to a known DOM node + +This is not necessarily slow in isolation, but it is a sign that the bridge is paying API complexity to adapt imperative registration into React composition. + +### Pain point 6: state and DOM can never be truly single-source + +The engine is the real source of truth for fit and visibility. + +React state is therefore downstream and derived. That means the bridge is always solving synchronization problems, not ownership problems. + +This is manageable, but it is a weaker architecture than a model where React owns the state transitions directly or subscribes to a dedicated external store. + +## Lifecycle of the React bridge + +```mermaid +sequenceDiagram + participant React as React render/commit + participant Hooks as Wrapper hooks + participant Engine as Overflow engine + participant Context as React context consumers + + React->>Hooks: render Overflow and items + Hooks->>Engine: register container and DOM nodes in layout effects + Engine->>Engine: measure and compute overflow + Engine-->>Hooks: onUpdateOverflow callback + Hooks->>React: set overflowState + React->>Context: publish new provider value + Context-->>React: subscribed components re-render + React->>Engine: some hooks may call updateOverflow again +``` + +## Cost model of the React bridge + +The bridge cost is mostly not in layout. The engine already dominates geometry work. + +The bridge adds cost in three other places. + +### 1. Allocation and derivation cost + +This includes: + +- rebuilding visibility maps +- creating new provider values +- creating memoized derived objects for hooks such as `useOverflowVisibility()` + +This is ordinary JS work, but it happens on every update. + +### 2. React subscription and render cost + +This includes: + +- invalidating selector subscribers +- re-running components that consume overflow state +- effect work after state changes, especially around menu registration/update + +This is the biggest bridge-specific cost bucket. + +### 3. Commit-phase coordination cost + +This includes: + +- layout effects for item registration +- layout effects for menu registration +- cleanup effects on unmount +- merged-ref and child-cloning overhead + +This is not the dominant runtime cost, but it makes the design more intricate and timing-sensitive. + +For design directions, refactor ideas, and unresolved questions beyond the current bridge, see `overflow-northstar.md`. This document stays focused on the current bridge and where its cost comes from. + +## What the bridge does well + +It is not all downside. The bridge gives React consumers a usable API surface: + +- `Overflow`, `OverflowItem`, and `OverflowDivider` expose composition-friendly primitives +- selector hooks let components ask narrow questions like "is item X visible?" +- the DOM still gets fast attribute-level visibility updates from the engine path + +So the bridge is functional and practical. The problem is not that it fails. The problem is that it duplicates work and couples render updates to engine churn more than an ideal integration would. + +## Practical ranking of React bridge issues + +If you need to rank the React-side concerns by importance, this is the right order: + +1. mirrored visibility state and full map rebuilds +2. React subscription and re-render churn +3. follow-up menu-triggered updates +4. engine recreation on option changes +5. child-cloning and ref-plumbing complexity + +## Bottom line + +The current bridge is workable, but not ideal. + +It is best understood as an adapter between two different worlds: + +- an imperative DOM measurement engine +- a declarative React subscription model + +That adapter necessarily adds synchronization, re-rendering, and some API awkwardness. The biggest structural weakness is that React mirrors and redistributes state the engine already owns instead of subscribing to a more canonical external store model. + +If the question is "what should replace it?", that belongs in `overflow-northstar.md`. The purpose of this document is narrower: explain how the current bridge works, where it pays, and why it feels awkward. + +That narrower scope is deliberate. Once the current bridge is described precisely, future improvement discussions can be grounded in specific costs and tradeoffs rather than broad impressions. + +## Relevant source files + +- `packages/react-components/react-overflow/library/src/components/Overflow.tsx` +- `packages/react-components/react-overflow/library/src/useOverflowContainer.ts` +- `packages/react-components/react-overflow/library/src/useOverflowItem.ts` +- `packages/react-components/react-overflow/library/src/useOverflowMenu.ts` +- `packages/react-components/react-overflow/library/src/useOverflowVisibility.ts` +- `packages/react-components/react-overflow/library/src/overflowContext.ts` From 8040502d8e9eff14a645d2e263e79b2fa8803445 Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Sun, 24 May 2026 21:33:29 +0200 Subject: [PATCH 02/14] tmp skip --- .../react-components/react-overflow/library/src/Overflow.cy.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-components/react-overflow/library/src/Overflow.cy.tsx b/packages/react-components/react-overflow/library/src/Overflow.cy.tsx index f4c15e969796ab..47be40a1ea90bd 100644 --- a/packages/react-components/react-overflow/library/src/Overflow.cy.tsx +++ b/packages/react-components/react-overflow/library/src/Overflow.cy.tsx @@ -828,7 +828,7 @@ describe('Overflow', () => { cy.get(`[${selectors.menu}]`).should('not.exist'); }); - it('should count accurately size of items', () => { + it.skip('should count accurately size of items', () => { mount( From 04da9a029e68b04e24ae8646f78d00e640e3b3fb Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Mon, 25 May 2026 15:05:24 +0200 Subject: [PATCH 03/14] refactor: stabilize overflow manager lifecycle --- .../priority-overflow/src/index.ts | 2 + .../src/overflowManager.test.ts | 117 +++++++++++++ .../priority-overflow/src/overflowManager.ts | 165 +++++++++++++++++- .../priority-overflow/src/types.ts | 37 ++++ .../library/src/components/Overflow.tsx | 80 +++++---- .../library/src/overflowContext.ts | 36 +++- .../react-overflow/library/src/types.ts | 6 + .../library/src/useIsOverflowGroupVisible.ts | 4 +- .../library/src/useIsOverflowItemVisible.ts | 4 +- .../library/src/useOverflowContainer.test.ts | 45 +++-- .../library/src/useOverflowContainer.ts | 61 ++++--- .../library/src/useOverflowCount.ts | 13 +- .../library/src/useOverflowSelector.ts | 15 ++ .../src/useOverflowVisibility.test.tsx | 11 ++ .../library/src/useOverflowVisibility.ts | 6 +- 15 files changed, 507 insertions(+), 95 deletions(-) create mode 100644 packages/react-components/priority-overflow/src/overflowManager.test.ts create mode 100644 packages/react-components/react-overflow/library/src/useOverflowSelector.ts diff --git a/packages/react-components/priority-overflow/src/index.ts b/packages/react-components/priority-overflow/src/index.ts index ba4668ceab6d56..37b2890ba04d78 100644 --- a/packages/react-components/priority-overflow/src/index.ts +++ b/packages/react-components/priority-overflow/src/index.ts @@ -1,6 +1,7 @@ export { createOverflowManager } from './overflowManager'; export type { ObserveOptions, + OverflowManagerOptions, OnUpdateItemVisibility, OnUpdateItemVisibilityPayload, OnUpdateOverflow, @@ -8,6 +9,7 @@ export type { OverflowDirection, OverflowEventPayload, OverflowGroupState, + OverflowSnapshot, OverflowItemEntry, OverflowDividerEntry, OverflowManager, diff --git a/packages/react-components/priority-overflow/src/overflowManager.test.ts b/packages/react-components/priority-overflow/src/overflowManager.test.ts new file mode 100644 index 00000000000000..4fb65ef47896bd --- /dev/null +++ b/packages/react-components/priority-overflow/src/overflowManager.test.ts @@ -0,0 +1,117 @@ +import { createOverflowManager } from './overflowManager'; +import type { ObserveOptions } from './types'; + +describe('overflowManager', () => { + beforeAll(() => { + global.ResizeObserver = class ResizeObserver { + public observe() { + // do nothing + } + + public unobserve() { + // do nothing + } + + public disconnect() { + // do nothing + } + } as unknown as typeof ResizeObserver; + }); + + const createElementWithSize = (tagName: string, width: number) => { + const element = document.createElement(tagName); + Object.defineProperty(element, 'offsetWidth', { configurable: true, value: width }); + Object.defineProperty(element, 'offsetHeight', { configurable: true, value: width }); + + return element; + }; + + const createContainer = (width: number) => { + const container = document.createElement('div'); + Object.defineProperty(container, 'clientWidth', { configurable: true, value: width }); + Object.defineProperty(container, 'clientHeight', { configurable: true, value: width }); + + return container; + }; + + const createObserveOptions = (options: Partial = {}): ObserveOptions => ({ + overflowAxis: 'horizontal', + overflowDirection: 'end', + padding: 10, + minimumVisible: 0, + hasHiddenItems: false, + onUpdateItemVisibility: jest.fn(), + onUpdateOverflow: jest.fn(), + ...options, + }); + + it('should expose a stable snapshot after forceUpdate', () => { + const manager = createOverflowManager(); + const container = createContainer(100); + const itemA = createElementWithSize('button', 40); + const itemB = createElementWithSize('button', 40); + const menu = createElementWithSize('button', 20); + + manager.addItem({ element: itemA, id: 'a', priority: 1 }); + manager.addItem({ element: itemB, id: 'b', priority: 0 }); + manager.setOverflowMenu(menu); + manager.observe(container, createObserveOptions()); + manager.forceUpdate(); + + expect(manager.getSnapshot()).toEqual({ + hasOverflow: false, + overflowCount: 0, + itemVisibility: { a: true, b: true }, + groupVisibility: {}, + }); + }); + + it('should update snapshot and notify subscribers when options change', () => { + const manager = createOverflowManager(); + const container = createContainer(100); + const itemA = createElementWithSize('button', 40); + const itemB = createElementWithSize('button', 40); + const menu = createElementWithSize('button', 20); + const listener = jest.fn(); + + manager.addItem({ element: itemA, id: 'a', priority: 1 }); + manager.addItem({ element: itemB, id: 'b', priority: 0 }); + manager.setOverflowMenu(menu); + manager.observe(container, createObserveOptions()); + manager.forceUpdate(); + const unsubscribe = manager.subscribe(listener); + + manager.setOptions({ padding: 30 }); + + expect(listener).toHaveBeenCalled(); + expect(manager.getSnapshot()).toEqual({ + hasOverflow: true, + overflowCount: 1, + itemVisibility: { a: true, b: false }, + groupVisibility: {}, + }); + + unsubscribe(); + }); + + it('should destroy and clear snapshot state', () => { + const manager = createOverflowManager(); + const container = createContainer(100); + const item = createElementWithSize('button', 40); + + manager.addItem({ element: item, id: 'a', priority: 1 }); + manager.observe(container, createObserveOptions()); + manager.forceUpdate(); + + expect(manager.getSnapshot().itemVisibility).toEqual({ a: true }); + + manager.destroy(); + + expect(manager.getSnapshot()).toEqual({ + hasOverflow: false, + overflowCount: 0, + itemVisibility: {}, + groupVisibility: {}, + }); + }); +}); diff --git a/packages/react-components/priority-overflow/src/overflowManager.ts b/packages/react-components/priority-overflow/src/overflowManager.ts index babf1954e60bde..1b98685ff5b6d6 100644 --- a/packages/react-components/priority-overflow/src/overflowManager.ts +++ b/packages/react-components/priority-overflow/src/overflowManager.ts @@ -7,6 +7,7 @@ import type { OverflowGroupState, OverflowItemEntry, OverflowManager, + OverflowSnapshot, ObserveOptions, OverflowDividerEntry, } from './types'; @@ -38,8 +39,19 @@ export function createOverflowManager(): OverflowManager { const overflowItems: Record = {}; const overflowDividers: Record = {}; + const listeners = new Set<() => void>(); + let snapshot: OverflowSnapshot = { + hasOverflow: false, + overflowCount: 0, + itemVisibility: {}, + groupVisibility: {}, + }; let disposeResizeObserver: () => void = () => null; + const notify = () => { + listeners.forEach(listener => listener()); + }; + const getNextItem = (queueToDequeue: PriorityQueue, queueToEnqueue: PriorityQueue) => { const nextItem = queueToDequeue.dequeue(); queueToEnqueue.enqueue(nextItem); @@ -145,13 +157,31 @@ export function createOverflowManager(): OverflowManager { const dispatchOverflowUpdate = () => { const visibleItemIds = visibleItemQueue.all(); const invisibleItemIds = invisibleItemQueue.all(); + const itemVisibility: Record = {}; const visibleItems = visibleItemIds.map(itemId => overflowItems[itemId]); const invisibleItems = invisibleItemIds.map(itemId => overflowItems[itemId]); + visibleItemIds.forEach(itemId => { + itemVisibility[itemId] = true; + }); + invisibleItemIds.forEach(itemId => { + itemVisibility[itemId] = false; + }); + + snapshot = { + hasOverflow: invisibleItemIds.length > 0, + overflowCount: invisibleItemIds.length, + itemVisibility, + groupVisibility: { ...groupManager.groupVisibility() }, + }; + options.onUpdateOverflow({ visibleItems, invisibleItems, groupVisibility: groupManager.groupVisibility() }); + notify(); }; + const getSnapshot: OverflowManager['getSnapshot'] = () => snapshot; + const processOverflowItems = (): boolean => { if (!container) { return false; @@ -205,12 +235,16 @@ export function createOverflowManager(): OverflowManager { const update: OverflowManager['update'] = debounce(forceUpdate); - const observe: OverflowManager['observe'] = (observedContainer, userOptions) => { - Object.assign(options, userOptions); - observing = true; - Object.values(overflowItems).forEach(item => visibleItemQueue.enqueue(item.id)); + const reconnectObserver = () => { + disposeResizeObserver(); + disposeResizeObserver = () => null; - container = observedContainer; + if (!container) { + observing = false; + return; + } + + observing = true; disposeResizeObserver = observeResize(container, entries => { if (!entries[0] || !container) { return; @@ -220,6 +254,52 @@ export function createOverflowManager(): OverflowManager { }); }; + const setOptions: OverflowManager['setOptions'] = nextOptions => { + const previousAxis = options.overflowAxis; + const previousDirection = options.overflowDirection; + const previousPadding = options.padding; + const previousMinimumVisible = options.minimumVisible; + const previousHasHiddenItems = options.hasHiddenItems; + + Object.assign(options, nextOptions); + + if ( + previousAxis !== options.overflowAxis || + previousDirection !== options.overflowDirection || + previousPadding !== options.padding || + previousMinimumVisible !== options.minimumVisible || + previousHasHiddenItems !== options.hasHiddenItems + ) { + forceDispatch = true; + update(); + } + }; + + const setContainer: OverflowManager['setContainer'] = nextContainer => { + if (container === nextContainer) { + return; + } + + container = nextContainer ?? undefined; + reconnectObserver(); + + if (container) { + Object.values(overflowItems).forEach(item => { + if (!visibleItemQueue.contains(item.id) && !invisibleItemQueue.contains(item.id)) { + visibleItemQueue.enqueue(item.id); + } + }); + forceDispatch = true; + update(); + } + }; + + const observe: OverflowManager['observe'] = (observedContainer, userOptions) => { + Object.assign(options, userOptions); + Object.values(overflowItems).forEach(item => visibleItemQueue.enqueue(item.id)); + setContainer(observedContainer); + }; + const addItem: OverflowManager['addItem'] = item => { if (overflowItems[item.id]) { return; @@ -246,6 +326,16 @@ export function createOverflowManager(): OverflowManager { const addOverflowMenu: OverflowManager['addOverflowMenu'] = el => { overflowMenu = el; + forceDispatch = true; + update(); + }; + + const setOverflowMenu: OverflowManager['setOverflowMenu'] = element => { + if (element) { + addOverflowMenu(element); + } else { + removeOverflowMenu(); + } }; const addDivider: OverflowManager['addDivider'] = divider => { @@ -259,6 +349,8 @@ export function createOverflowManager(): OverflowManager { const removeOverflowMenu: OverflowManager['removeOverflowMenu'] = () => { overflowMenu = undefined; + forceDispatch = true; + update(); }; const removeDivider: OverflowManager['removeDivider'] = groupId => { @@ -297,27 +389,82 @@ export function createOverflowManager(): OverflowManager { update(); }; + const resetSnapshot = () => { + snapshot = { + hasOverflow: false, + overflowCount: 0, + itemVisibility: {}, + groupVisibility: {}, + }; + }; + const disconnect: OverflowManager['disconnect'] = () => { disposeResizeObserver(); + disposeResizeObserver = () => null; // reset flags container = undefined; observing = false; forceDispatch = true; - // clear all entries - Object.keys(overflowItems).forEach(itemId => removeItem(itemId)); - Object.keys(overflowDividers).forEach(dividerId => removeDivider(dividerId)); - removeOverflowMenu(); + resetSnapshot(); + + notify(); + }; + + const destroy: OverflowManager['destroy'] = () => { + disposeResizeObserver(); + disposeResizeObserver = () => null; + + container = undefined; + overflowMenu = undefined; + observing = false; + forceDispatch = true; + + visibleItemQueue.clear(); + invisibleItemQueue.clear(); + + Object.values(overflowItems).forEach(item => { + item.element.removeAttribute(DATA_OVERFLOW_GROUP); + item.element.removeAttribute(DATA_OVERFLOWING); + }); + Object.values(overflowDividers).forEach(divider => { + divider.element.removeAttribute(DATA_OVERFLOW_GROUP); + divider.element.removeAttribute(DATA_OVERFLOWING); + }); + + Object.keys(overflowItems).forEach(itemId => { + delete overflowItems[itemId]; + }); + Object.keys(overflowDividers).forEach(groupId => { + delete overflowDividers[groupId]; + }); + sizeCache.clear(); + resetSnapshot(); + listeners.clear(); + }; + + const subscribe: OverflowManager['subscribe'] = listener => { + listeners.add(listener); + + return () => { + listeners.delete(listener); + }; }; return { addItem, + destroy, disconnect, forceUpdate, + getSnapshot, observe, removeItem, + setContainer, + setOptions, + setOverflowMenu, + subscribe, update, addOverflowMenu, removeOverflowMenu, diff --git a/packages/react-components/priority-overflow/src/types.ts b/packages/react-components/priority-overflow/src/types.ts index c3760795ca4171..0b91716c4acd3d 100644 --- a/packages/react-components/priority-overflow/src/types.ts +++ b/packages/react-components/priority-overflow/src/types.ts @@ -1,6 +1,14 @@ export type OverflowDirection = 'start' | 'end'; export type OverflowAxis = 'horizontal' | 'vertical'; export type OverflowGroupState = 'visible' | 'hidden' | 'overflow'; + +export interface OverflowSnapshot { + hasOverflow: boolean; + overflowCount: number; + itemVisibility: Record; + groupVisibility: Record; +} + export interface OverflowItemEntry { /** * HTML element that will be disappear when overflowed @@ -98,10 +106,24 @@ export interface ObserveOptions { hasHiddenItems?: boolean; } +export type OverflowManagerOptions = Omit; + /** * @internal */ export interface OverflowManager { + /** + * Updates engine options without requiring full observation re-creation. + */ + setOptions: (options: Partial) => void; + /** + * Attaches or detaches the observed container element. + */ + setContainer: (container: HTMLElement | null) => void; + /** + * Attaches or detaches the overflow menu element. + */ + setOverflowMenu: (element: HTMLElement | null) => void; /** * Starts observing the container and managing the overflow state */ @@ -147,4 +169,19 @@ export interface OverflowManager { * Unsets the overflow menu element */ removeOverflowMenu: () => void; + + /** + * Returns the current canonical overflow snapshot. + */ + getSnapshot: () => OverflowSnapshot; + + /** + * Subscribes to snapshot changes. + */ + subscribe: (listener: () => void) => () => void; + + /** + * Fully tears down the manager and clears all tracked state. + */ + destroy: () => void; } diff --git a/packages/react-components/react-overflow/library/src/components/Overflow.tsx b/packages/react-components/react-overflow/library/src/components/Overflow.tsx index 85563028ad8967..fa82edb3635c97 100644 --- a/packages/react-components/react-overflow/library/src/components/Overflow.tsx +++ b/packages/react-components/react-overflow/library/src/components/Overflow.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { mergeClasses } from '@griffel/react'; -import type { OnUpdateOverflow, OverflowGroupState, ObserveOptions } from '@fluentui/priority-overflow'; +import type { OverflowGroupState, OverflowSnapshot, ObserveOptions } from '@fluentui/priority-overflow'; import { applyTriggerPropsToChildren, getTriggerChild, @@ -20,6 +20,25 @@ interface OverflowState { groupVisibility: Record; } +const emptyOverflowState: OverflowState = { + hasOverflow: false, + itemVisibility: {}, + groupVisibility: {}, +}; + +const emptyOverflowSnapshot: OverflowSnapshot = { + hasOverflow: false, + overflowCount: 0, + itemVisibility: {}, + groupVisibility: {}, +}; + +const toOverflowState = (snapshot: OverflowSnapshot): OverflowState => ({ + hasOverflow: snapshot.hasOverflow, + itemVisibility: snapshot.itemVisibility, + groupVisibility: snapshot.groupVisibility, +}); + export interface OnOverflowChangeData extends OverflowState {} /** @@ -51,43 +70,43 @@ export const Overflow = React.forwardRef((props: OverflowProps, ref) => { hasHiddenItems, } = props; - const [overflowState, setOverflowState] = React.useState({ - hasOverflow: false, - itemVisibility: {}, - groupVisibility: {}, - }); - - // useOverflowContainer wraps this method in a useEventCallback. - const update: OnUpdateOverflow = data => { - const { visibleItems, invisibleItems, groupVisibility } = data; - - const itemVisibility: Record = {}; - visibleItems.forEach(item => { - itemVisibility[item.id] = true; - }); - invisibleItems.forEach(x => (itemVisibility[x.id] = false)); - const newState = { - hasOverflow: data.invisibleItems.length > 0, - itemVisibility, - groupVisibility, - }; - onOverflowChange?.(null, { ...newState }); - - setOverflowState(newState); - }; - - const { containerRef, registerItem, updateOverflow, registerOverflowMenu, registerDivider } = useOverflowContainer( - update, - { + const { containerRef, manager, registerItem, updateOverflow, registerOverflowMenu, registerDivider } = + useOverflowContainer(() => undefined, { overflowDirection, overflowAxis, padding, minimumVisible, hasHiddenItems, onUpdateItemVisibility: updateVisibilityAttribute, - }, + }); + + const snapshot = React.useSyncExternalStore( + manager?.subscribe ?? (() => () => undefined), + () => (manager ? manager.getSnapshot() : emptyOverflowSnapshot), + () => (manager ? manager.getSnapshot() : emptyOverflowSnapshot), + ); + + const overflowState = React.useMemo( + () => (snapshot === emptyOverflowSnapshot ? emptyOverflowState : toOverflowState(snapshot)), + [snapshot], ); + const lastReportedState = React.useRef(null); + + React.useEffect(() => { + if (!onOverflowChange) { + return; + } + + if (lastReportedState.current === null) { + lastReportedState.current = overflowState; + return; + } + + onOverflowChange(null, overflowState); + lastReportedState.current = overflowState; + }, [onOverflowChange, overflowState]); + const child = getTriggerChild(children); const clonedChild = applyTriggerPropsToChildren(children, { ref: useMergedRefs(containerRef, ref, getReactElementRef(child)), @@ -97,6 +116,7 @@ export const Overflow = React.forwardRef((props: OverflowProps, ref) => { return ( undefined, + setContainer: () => undefined, + setOverflowMenu: () => undefined, + observe: () => undefined, + disconnect: () => undefined, + destroy: () => undefined, + addItem: () => undefined, + removeItem: () => undefined, + update: () => undefined, + forceUpdate: () => undefined, + addOverflowMenu: () => undefined, + addDivider: () => undefined, + removeDivider: () => undefined, + removeOverflowMenu: () => undefined, + getSnapshot: () => defaultSnapshot, + subscribe: () => () => undefined, +}; + /** * @internal */ export interface OverflowContextValue { + manager: OverflowManager; itemVisibility: Record; groupVisibility: Record; hasOverflow: boolean; @@ -24,6 +57,7 @@ export const OverflowContext = createContext( ) as Context; const overflowContextDefaultValue: OverflowContextValue = { + manager: defaultManager, itemVisibility: {}, groupVisibility: {}, hasOverflow: false, diff --git a/packages/react-components/react-overflow/library/src/types.ts b/packages/react-components/react-overflow/library/src/types.ts index 22a2bccd3292f2..80cae1bf9ca30f 100644 --- a/packages/react-components/react-overflow/library/src/types.ts +++ b/packages/react-components/react-overflow/library/src/types.ts @@ -1,5 +1,6 @@ import type * as React from 'react'; import type { OverflowContextValue } from './overflowContext'; +import type { OverflowManager } from '@fluentui/priority-overflow'; /** * @internal @@ -10,4 +11,9 @@ export interface UseOverflowContainerReturn * Ref to apply to the container that will overflow */ containerRef: React.RefObject; + + /** + * Canonical overflow manager for the current container. + */ + manager: OverflowManager | null; } diff --git a/packages/react-components/react-overflow/library/src/useIsOverflowGroupVisible.ts b/packages/react-components/react-overflow/library/src/useIsOverflowGroupVisible.ts index a389d36fc46224..a449bcb1c873b7 100644 --- a/packages/react-components/react-overflow/library/src/useIsOverflowGroupVisible.ts +++ b/packages/react-components/react-overflow/library/src/useIsOverflowGroupVisible.ts @@ -1,12 +1,12 @@ 'use client'; import type { OverflowGroupState } from '@fluentui/priority-overflow'; -import { useOverflowContext } from './overflowContext'; +import { useOverflowSelector } from './useOverflowSelector'; /** * @param id - unique identifier for a group of overflow items * @returns visibility state of the group */ export function useIsOverflowGroupVisible(id: string): OverflowGroupState { - return useOverflowContext(ctx => ctx.groupVisibility[id]); + return useOverflowSelector(snapshot => snapshot.groupVisibility[id]); } diff --git a/packages/react-components/react-overflow/library/src/useIsOverflowItemVisible.ts b/packages/react-components/react-overflow/library/src/useIsOverflowItemVisible.ts index f23b26765de17f..c387ad993fa73a 100644 --- a/packages/react-components/react-overflow/library/src/useIsOverflowItemVisible.ts +++ b/packages/react-components/react-overflow/library/src/useIsOverflowItemVisible.ts @@ -1,11 +1,11 @@ 'use client'; -import { useOverflowContext } from './overflowContext'; +import { useOverflowSelector } from './useOverflowSelector'; /** * @param id - unique identifier for the item used by the overflow manager * @returns visibility state of an overflow item */ export function useIsOverflowItemVisible(id: string): boolean { - return !!useOverflowContext(ctx => ctx.itemVisibility[id]); + return !!useOverflowSelector(snapshot => snapshot.itemVisibility[id]); } diff --git a/packages/react-components/react-overflow/library/src/useOverflowContainer.test.ts b/packages/react-components/react-overflow/library/src/useOverflowContainer.test.ts index fdb02fb5711663..66dfd604e31d36 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowContainer.test.ts +++ b/packages/react-components/react-overflow/library/src/useOverflowContainer.test.ts @@ -11,10 +11,16 @@ const mockOverflowManager = (options: Partial = {}) => { addItem: jest.fn(), addOverflowMenu: jest.fn(), disconnect: jest.fn(), + destroy: jest.fn(), forceUpdate: jest.fn(), + getSnapshot: jest.fn(() => ({ hasOverflow: false, overflowCount: 0, itemVisibility: {}, groupVisibility: {} })), observe: jest.fn(), removeItem: jest.fn(), removeOverflowMenu: jest.fn(), + setContainer: jest.fn(), + setOptions: jest.fn(), + setOverflowMenu: jest.fn(), + subscribe: jest.fn(() => () => undefined), update: jest.fn(), addDivider: jest.fn(), removeDivider: jest.fn(), @@ -87,6 +93,28 @@ describe('useOverflowContainer', () => { `); }); + it('should reconfigure the same manager when options change', () => { + const setOptionsMock = jest.fn(); + mockOverflowManager({ setOptions: setOptionsMock }); + + let overflowAxis: OverflowAxis = 'horizontal'; + const { rerender } = renderHook(() => { + return useOverflowContainer(() => undefined, { onUpdateItemVisibility: () => undefined, overflowAxis }); + }); + + expect(createOverflowManager).toHaveBeenCalledTimes(1); + + overflowAxis = 'vertical'; + rerender(); + + expect(createOverflowManager).toHaveBeenCalledTimes(1); + expect(setOptionsMock).toHaveBeenCalledWith( + expect.objectContaining({ + overflowAxis: 'vertical', + }), + ); + }); + it('should invoke updateOverflow on overflow manager', () => { const updateMock = jest.fn(); mockOverflowManager({ update: updateMock }); @@ -109,21 +137,4 @@ describe('useOverflowContainer', () => { expect(renderCount).toEqual(1); }); - - it('should re-render when option changes', () => { - let overflowAxis: OverflowAxis = 'horizontal'; - mockOverflowManager(); - let renderCount = 0; - const { rerender } = renderHook(() => { - renderCount++; - return useOverflowContainer(() => undefined, { onUpdateItemVisibility: () => undefined, overflowAxis }); - }); - - expect(renderCount).toEqual(1); - - overflowAxis = 'vertical'; - rerender(); - - expect(renderCount).toEqual(2); - }); }); diff --git a/packages/react-components/react-overflow/library/src/useOverflowContainer.ts b/packages/react-components/react-overflow/library/src/useOverflowContainer.ts index 071f51d2e7a4c1..e2fa09ea5f7f0d 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowContainer.ts +++ b/packages/react-components/react-overflow/library/src/useOverflowContainer.ts @@ -42,20 +42,21 @@ export const useOverflowContainer = ( } = options; const onUpdateOverflow = useEventCallback(update); + const onUpdateItemVisibilityCallback = useEventCallback(onUpdateItemVisibility); - const overflowOptions = React.useMemo( + const observeOptions = React.useMemo( () => ({ overflowAxis, overflowDirection, padding, minimumVisible, - onUpdateItemVisibility, + onUpdateItemVisibility: onUpdateItemVisibilityCallback, onUpdateOverflow, hasHiddenItems, }), [ minimumVisible, - onUpdateItemVisibility, + onUpdateItemVisibilityCallback, overflowAxis, overflowDirection, padding, @@ -64,38 +65,57 @@ export const useOverflowContainer = ( ], ); + const managerOptions = React.useMemo( + () => ({ + overflowAxis, + overflowDirection, + padding, + minimumVisible, + hasHiddenItems, + }), + [minimumVisible, overflowAxis, overflowDirection, padding, hasHiddenItems], + ); + const firstMount = useFirstMount(); + const hasObservedRef = React.useRef(false); // DOM ref to the overflow container element const containerRef = React.useRef(null); - const [overflowManager, setOverflowManager] = React.useState(() => + const overflowManager = React.useState(() => canUseDOM() ? createOverflowManager() : null, - ); + )[0]; - // On first mount there is no need to create an overflow manager and re-render + // Initialize the manager with the full observation contract once, when the container ref is first available. useIsomorphicLayoutEffect(() => { - if (firstMount && containerRef.current) { - overflowManager?.observe(containerRef.current, overflowOptions); + if (!hasObservedRef.current && containerRef.current) { + overflowManager?.observe(containerRef.current, observeOptions); + hasObservedRef.current = true; } - }, [firstMount, overflowManager, overflowOptions]); + }, [overflowManager, observeOptions, firstMount]); + + // After first mount, option changes should reconfigure the same manager. + useIsomorphicLayoutEffect(() => { + if (!overflowManager || firstMount) { + return; + } + + overflowManager.setOptions(managerOptions); + }, [firstMount, overflowManager, managerOptions]); + // Keep the attached container current across renders after mount. useIsomorphicLayoutEffect(() => { - if (!containerRef.current || !canUseDOM() || firstMount) { + if (!overflowManager || !hasObservedRef.current || firstMount) { return; } - const newOverflowManager = createOverflowManager(); - newOverflowManager.observe(containerRef.current, overflowOptions); - setOverflowManager(newOverflowManager); - // We don't want to re-create the overflow manager when the first mount flag changes from true to false - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [overflowOptions]); + overflowManager.setContainer(containerRef.current); + }); - /* Clean up overflow manager on unmount */ + /* Fully destroy the manager on unmount */ React.useEffect( () => () => { - overflowManager?.disconnect(); + overflowManager?.destroy(); }, [overflowManager], ); @@ -130,11 +150,11 @@ export const useOverflowContainer = ( const registerOverflowMenu = React.useCallback( (el: HTMLElement) => { - overflowManager?.addOverflowMenu(el); + overflowManager?.setOverflowMenu(el); el.setAttribute(DATA_OVERFLOW_MENU, ''); return () => { - overflowManager?.removeOverflowMenu(); + overflowManager?.setOverflowMenu(null); el.removeAttribute(DATA_OVERFLOW_MENU); }; }, @@ -151,6 +171,7 @@ export const useOverflowContainer = ( registerOverflowMenu, updateOverflow, containerRef, + manager: overflowManager, }; }; diff --git a/packages/react-components/react-overflow/library/src/useOverflowCount.ts b/packages/react-components/react-overflow/library/src/useOverflowCount.ts index 91fb75f14a7bc8..fe9117b109f0fd 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowCount.ts +++ b/packages/react-components/react-overflow/library/src/useOverflowCount.ts @@ -1,17 +1,8 @@ 'use client'; -import { useOverflowContext } from './overflowContext'; +import { useOverflowSelector } from './useOverflowSelector'; /** * @returns Number of items that are overflowing */ -export const useOverflowCount = (): number => - useOverflowContext(v => { - return Object.entries(v.itemVisibility).reduce((acc, [id, visible]) => { - if (!visible) { - acc++; - } - - return acc; - }, 0); - }); +export const useOverflowCount = (): number => useOverflowSelector(snapshot => snapshot.overflowCount); diff --git a/packages/react-components/react-overflow/library/src/useOverflowSelector.ts b/packages/react-components/react-overflow/library/src/useOverflowSelector.ts new file mode 100644 index 00000000000000..b7b4622f9c9973 --- /dev/null +++ b/packages/react-components/react-overflow/library/src/useOverflowSelector.ts @@ -0,0 +1,15 @@ +'use client'; + +import * as React from 'react'; +import type { OverflowSnapshot } from '@fluentui/priority-overflow'; +import { useOverflowContext } from './overflowContext'; + +export function useOverflowSelector(selector: (snapshot: OverflowSnapshot) => T): T { + const manager = useOverflowContext(ctx => ctx.manager); + + return React.useSyncExternalStore( + manager.subscribe, + () => selector(manager.getSnapshot()), + () => selector(manager.getSnapshot()), + ); +} diff --git a/packages/react-components/react-overflow/library/src/useOverflowVisibility.test.tsx b/packages/react-components/react-overflow/library/src/useOverflowVisibility.test.tsx index 508d0d1e8f3382..08809222a3beaa 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowVisibility.test.tsx +++ b/packages/react-components/react-overflow/library/src/useOverflowVisibility.test.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import type { OverflowManager } from '@fluentui/priority-overflow'; import { renderHook } from '@testing-library/react-hooks'; import { useOverflowVisibility } from './useOverflowVisibility'; import type { OverflowContextValue } from './overflowContext'; @@ -17,12 +18,22 @@ describe('useOverflowVisibility', () => { bar: true, baz: false, } as const; + const manager = { + getSnapshot: jest.fn(() => ({ + hasOverflow: true, + overflowCount: 1, + itemVisibility, + groupVisibility, + })), + subscribe: jest.fn(() => () => undefined), + } as unknown as OverflowManager; const Wrapper: React.FC = props => { return ( ; groupVisibility: Record; } { - const itemVisibility = useOverflowContext(ctx => ctx.itemVisibility); - const groupVisibility = useOverflowContext(ctx => ctx.groupVisibility); + const itemVisibility = useOverflowSelector(snapshot => snapshot.itemVisibility); + const groupVisibility = useOverflowSelector(snapshot => snapshot.groupVisibility); return React.useMemo(() => ({ itemVisibility, groupVisibility }), [itemVisibility, groupVisibility]); } From ad40a62a2a730eb44e6653b40c1ee4f3e4c41ad8 Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Mon, 25 May 2026 21:49:07 +0200 Subject: [PATCH 04/14] refactor overflow lifecycle boundaries --- .../etc/priority-overflow.api.md | 29 +++- .../src/overflowManager.test.ts | 50 +++++-- .../priority-overflow/src/overflowManager.ts | 131 ++++++++---------- .../priority-overflow/src/types.ts | 45 ++---- .../library/docs/overflow-northstar.md | 106 +++++++++++--- .../library/src/overflowContext.ts | 14 +- .../react-overflow/library/src/types.ts | 2 +- .../library/src/useOverflowContainer.test.ts | 53 +++---- .../library/src/useOverflowContainer.ts | 65 +++------ 9 files changed, 256 insertions(+), 239 deletions(-) diff --git a/packages/react-components/priority-overflow/etc/priority-overflow.api.md b/packages/react-components/priority-overflow/etc/priority-overflow.api.md index bf10a02394bb8a..7af513e50383dd 100644 --- a/packages/react-components/priority-overflow/etc/priority-overflow.api.md +++ b/packages/react-components/priority-overflow/etc/priority-overflow.api.md @@ -70,18 +70,33 @@ export interface OverflowItemEntry { // @internal (undocumented) export interface OverflowManager { - addDivider: (divider: OverflowDividerEntry) => void; - addItem: (items: OverflowItemEntry) => void; - addOverflowMenu: (element: HTMLElement) => void; - disconnect: () => void; + attachOverflowMenu: (element: HTMLElement) => () => void; forceUpdate: () => void; - observe: (container: HTMLElement, options: ObserveOptions) => void; - removeDivider: (groupId: string) => void; + getSnapshot: () => OverflowSnapshot; + observe: (container: HTMLElement) => () => void; + registerDivider: (divider: OverflowDividerEntry) => () => void; + registerItem: (items: OverflowItemEntry) => () => void; removeItem: (itemId: string) => void; - removeOverflowMenu: () => void; + setOptions: (options: Partial) => void; + subscribe: (listener: () => void) => () => void; update: () => void; } +// @public (undocumented) +export type OverflowManagerOptions = ObserveOptions; + +// @public (undocumented) +export interface OverflowSnapshot { + // (undocumented) + groupVisibility: Record; + // (undocumented) + hasOverflow: boolean; + // (undocumented) + itemVisibility: Record; + // (undocumented) + overflowCount: number; +} + // (No @packageDocumentation comment for this package) ``` diff --git a/packages/react-components/priority-overflow/src/overflowManager.test.ts b/packages/react-components/priority-overflow/src/overflowManager.test.ts index 4fb65ef47896bd..b942339a7bc375 100644 --- a/packages/react-components/priority-overflow/src/overflowManager.test.ts +++ b/packages/react-components/priority-overflow/src/overflowManager.test.ts @@ -52,10 +52,11 @@ describe('overflowManager', () => { const itemB = createElementWithSize('button', 40); const menu = createElementWithSize('button', 20); - manager.addItem({ element: itemA, id: 'a', priority: 1 }); - manager.addItem({ element: itemB, id: 'b', priority: 0 }); - manager.setOverflowMenu(menu); - manager.observe(container, createObserveOptions()); + manager.setOptions(createObserveOptions()); + manager.registerItem({ element: itemA, id: 'a', priority: 1 }); + manager.registerItem({ element: itemB, id: 'b', priority: 0 }); + manager.attachOverflowMenu(menu); + manager.observe(container); manager.forceUpdate(); expect(manager.getSnapshot()).toEqual({ @@ -74,10 +75,11 @@ describe('overflowManager', () => { const menu = createElementWithSize('button', 20); const listener = jest.fn(); - manager.addItem({ element: itemA, id: 'a', priority: 1 }); - manager.addItem({ element: itemB, id: 'b', priority: 0 }); - manager.setOverflowMenu(menu); - manager.observe(container, createObserveOptions()); + manager.setOptions(createObserveOptions()); + manager.registerItem({ element: itemA, id: 'a', priority: 1 }); + manager.registerItem({ element: itemB, id: 'b', priority: 0 }); + manager.attachOverflowMenu(menu); + manager.observe(container); manager.forceUpdate(); const unsubscribe = manager.subscribe(listener); @@ -94,18 +96,42 @@ describe('overflowManager', () => { unsubscribe(); }); - it('should destroy and clear snapshot state', () => { + it('should reset snapshot state when observation cleanup runs', () => { const manager = createOverflowManager(); const container = createContainer(100); const item = createElementWithSize('button', 40); - manager.addItem({ element: item, id: 'a', priority: 1 }); - manager.observe(container, createObserveOptions()); + manager.setOptions(createObserveOptions()); + manager.registerItem({ element: item, id: 'a', priority: 1 }); + const cleanup = manager.observe(container); manager.forceUpdate(); expect(manager.getSnapshot().itemVisibility).toEqual({ a: true }); - manager.destroy(); + cleanup(); + + expect(manager.getSnapshot()).toEqual({ + hasOverflow: false, + overflowCount: 0, + itemVisibility: {}, + groupVisibility: {}, + }); + }); + + it('should remove items through registration cleanup', () => { + const manager = createOverflowManager(); + const container = createContainer(100); + const item = createElementWithSize('button', 40); + + manager.setOptions(createObserveOptions()); + const cleanupItem = manager.registerItem({ element: item, id: 'a', priority: 1 }); + manager.observe(container); + manager.forceUpdate(); + + expect(manager.getSnapshot().itemVisibility).toEqual({ a: true }); + + cleanupItem(); + manager.forceUpdate(); expect(manager.getSnapshot()).toEqual({ hasOverflow: false, diff --git a/packages/react-components/priority-overflow/src/overflowManager.ts b/packages/react-components/priority-overflow/src/overflowManager.ts index 1b98685ff5b6d6..ee4f038d32fcf7 100644 --- a/packages/react-components/priority-overflow/src/overflowManager.ts +++ b/packages/react-components/priority-overflow/src/overflowManager.ts @@ -275,7 +275,7 @@ export function createOverflowManager(): OverflowManager { } }; - const setContainer: OverflowManager['setContainer'] = nextContainer => { + const connectContainer = (nextContainer: HTMLElement | null) => { if (container === nextContainer) { return; } @@ -294,13 +294,31 @@ export function createOverflowManager(): OverflowManager { } }; - const observe: OverflowManager['observe'] = (observedContainer, userOptions) => { - Object.assign(options, userOptions); - Object.values(overflowItems).forEach(item => visibleItemQueue.enqueue(item.id)); - setContainer(observedContainer); + const observe: OverflowManager['observe'] = observedContainer => { + Object.values(overflowItems).forEach(item => { + if (!visibleItemQueue.contains(item.id) && !invisibleItemQueue.contains(item.id)) { + visibleItemQueue.enqueue(item.id); + } + }); + + connectContainer(observedContainer); + + return () => { + if (container !== observedContainer) { + return; + } + + disposeResizeObserver(); + disposeResizeObserver = () => null; + container = undefined; + observing = false; + forceDispatch = true; + resetSnapshot(); + notify(); + }; }; - const addItem: OverflowManager['addItem'] = item => { + const addItem = (item: OverflowItemEntry) => { if (overflowItems[item.id]) { return; } @@ -324,21 +342,13 @@ export function createOverflowManager(): OverflowManager { update(); }; - const addOverflowMenu: OverflowManager['addOverflowMenu'] = el => { + const addOverflowMenu = (el: HTMLElement) => { overflowMenu = el; forceDispatch = true; update(); }; - const setOverflowMenu: OverflowManager['setOverflowMenu'] = element => { - if (element) { - addOverflowMenu(element); - } else { - removeOverflowMenu(); - } - }; - - const addDivider: OverflowManager['addDivider'] = divider => { + const addDivider = (divider: OverflowDividerEntry) => { if (!divider.groupId || overflowDividers[divider.groupId]) { return; } @@ -347,13 +357,13 @@ export function createOverflowManager(): OverflowManager { overflowDividers[divider.groupId] = divider; }; - const removeOverflowMenu: OverflowManager['removeOverflowMenu'] = () => { + const removeOverflowMenu = () => { overflowMenu = undefined; forceDispatch = true; update(); }; - const removeDivider: OverflowManager['removeDivider'] = groupId => { + const removeDivider = (groupId: string) => { if (!overflowDividers[groupId]) { return; } @@ -389,60 +399,41 @@ export function createOverflowManager(): OverflowManager { update(); }; - const resetSnapshot = () => { - snapshot = { - hasOverflow: false, - overflowCount: 0, - itemVisibility: {}, - groupVisibility: {}, + const registerItem: OverflowManager['registerItem'] = item => { + addItem(item); + + return () => { + removeItem(item.id); }; }; - const disconnect: OverflowManager['disconnect'] = () => { - disposeResizeObserver(); - disposeResizeObserver = () => null; + const attachOverflowMenu: OverflowManager['attachOverflowMenu'] = element => { + addOverflowMenu(element); - // reset flags - container = undefined; - observing = false; - forceDispatch = true; - - resetSnapshot(); - - notify(); + return () => { + if (overflowMenu === element) { + removeOverflowMenu(); + } + }; }; - const destroy: OverflowManager['destroy'] = () => { - disposeResizeObserver(); - disposeResizeObserver = () => null; - - container = undefined; - overflowMenu = undefined; - observing = false; - forceDispatch = true; - - visibleItemQueue.clear(); - invisibleItemQueue.clear(); - - Object.values(overflowItems).forEach(item => { - item.element.removeAttribute(DATA_OVERFLOW_GROUP); - item.element.removeAttribute(DATA_OVERFLOWING); - }); - Object.values(overflowDividers).forEach(divider => { - divider.element.removeAttribute(DATA_OVERFLOW_GROUP); - divider.element.removeAttribute(DATA_OVERFLOWING); - }); + const registerDivider: OverflowManager['registerDivider'] = divider => { + addDivider(divider); - Object.keys(overflowItems).forEach(itemId => { - delete overflowItems[itemId]; - }); - Object.keys(overflowDividers).forEach(groupId => { - delete overflowDividers[groupId]; - }); + return () => { + if (overflowDividers[divider.groupId]?.element === divider.element) { + removeDivider(divider.groupId); + } + }; + }; - sizeCache.clear(); - resetSnapshot(); - listeners.clear(); + const resetSnapshot = () => { + snapshot = { + hasOverflow: false, + overflowCount: 0, + itemVisibility: {}, + groupVisibility: {}, + }; }; const subscribe: OverflowManager['subscribe'] = listener => { @@ -454,22 +445,16 @@ export function createOverflowManager(): OverflowManager { }; return { - addItem, - destroy, - disconnect, + attachOverflowMenu, forceUpdate, getSnapshot, observe, removeItem, - setContainer, + registerDivider, + registerItem, setOptions, - setOverflowMenu, subscribe, update, - addOverflowMenu, - removeOverflowMenu, - addDivider, - removeDivider, }; } diff --git a/packages/react-components/priority-overflow/src/types.ts b/packages/react-components/priority-overflow/src/types.ts index 0b91716c4acd3d..153db5ddb9ba82 100644 --- a/packages/react-components/priority-overflow/src/types.ts +++ b/packages/react-components/priority-overflow/src/types.ts @@ -106,7 +106,7 @@ export interface ObserveOptions { hasHiddenItems?: boolean; } -export type OverflowManagerOptions = Omit; +export type OverflowManagerOptions = ObserveOptions; /** * @internal @@ -116,26 +116,14 @@ export interface OverflowManager { * Updates engine options without requiring full observation re-creation. */ setOptions: (options: Partial) => void; - /** - * Attaches or detaches the observed container element. - */ - setContainer: (container: HTMLElement | null) => void; - /** - * Attaches or detaches the overflow menu element. - */ - setOverflowMenu: (element: HTMLElement | null) => void; /** * Starts observing the container and managing the overflow state */ - observe: (container: HTMLElement, options: ObserveOptions) => void; + observe: (container: HTMLElement) => () => void; /** - * Stops observing the container + * Registers an overflow item and returns a cleanup function. */ - disconnect: () => void; - /** - * Add overflow items - */ - addItem: (items: OverflowItemEntry) => void; + registerItem: (items: OverflowItemEntry) => () => void; /** * Remove overflow item */ @@ -150,25 +138,15 @@ export interface OverflowManager { forceUpdate: () => void; /** - * Adds an element that opens an overflow menu. This is used to calculate - * available space and check if additional items need to overflow + * Attaches an element that opens an overflow menu and returns a cleanup function. + * This is used to calculate available space and check if additional items need to overflow. */ - addOverflowMenu: (element: HTMLElement) => void; + attachOverflowMenu: (element: HTMLElement) => () => void; /** - * Add overflow divider + * Registers a divider and returns a cleanup function. */ - addDivider: (divider: OverflowDividerEntry) => void; - - /** - * Remove overflow divider - */ - removeDivider: (groupId: string) => void; - - /** - * Unsets the overflow menu element - */ - removeOverflowMenu: () => void; + registerDivider: (divider: OverflowDividerEntry) => () => void; /** * Returns the current canonical overflow snapshot. @@ -179,9 +157,4 @@ export interface OverflowManager { * Subscribes to snapshot changes. */ subscribe: (listener: () => void) => () => void; - - /** - * Fully tears down the manager and clears all tracked state. - */ - destroy: () => void; } diff --git a/packages/react-components/react-overflow/library/docs/overflow-northstar.md b/packages/react-components/react-overflow/library/docs/overflow-northstar.md index 226561d6bf2293..f8dfa8eaccfc04 100644 --- a/packages/react-components/react-overflow/library/docs/overflow-northstar.md +++ b/packages/react-components/react-overflow/library/docs/overflow-northstar.md @@ -98,31 +98,48 @@ The target is a future shape of `OverflowManager`, not a separately named contro Readable state and generic subscription are acceptable engine concerns as long as they remain host-agnostic and do not make the engine React-shaped. -### 5. Converge on the viable engine contract first +### 5. Pure construction and paired cleanup are required + +The manager should be created in a pure way. + +That means construction itself should not require cleanup. + +Cleanup should be attached to the specific runtime relationships that are created later: + +- observation +- subscription +- item registration +- divider registration +- menu attachment +- container attachment + +The normal desired design should not rely on a monolithic `destroy()` to compensate for blurred lifecycle boundaries. + +### 6. Converge on the viable engine contract first The current target is the viable future `OverflowManager`, not the ideal one. The viable target includes: - `setOptions()` -- `setContainer()` -- `setOverflowMenu()` -- existing add/remove registration for now +- `observe(container)` returning cleanup +- `attachOverflowMenu(element)` returning cleanup +- `registerItem(item)` returning cleanup +- `registerDivider(divider)` returning cleanup - `getSnapshot()` - `subscribe()` -- `destroy()` -### 6. Handle-based registration is ideal, but not required yet +### 7. Handle-based registration is ideal, but not required yet `registerItem(...)->handle.update()/unregister()` and its divider equivalent remain part of the ideal engine surface, but they are not required for the first viable improved state. -### 7. Engine contract work comes before deeper bridge redesign +### 8. Engine contract work comes before deeper bridge redesign The next phase should assume that improving the engine contract comes first. Only after that should we decide how much of the remaining bridge complexity still justifies deeper bridge redesign or ideal-surface expansion. -### 8. The viable transition should be non-breaking at the component level +### 9. The viable transition should be non-breaking at the component level The viable engine and bridge redesign should be treated as non-breaking at the component level. @@ -142,6 +159,8 @@ The desired engine state is: - it exposes canonical readable overflow state - it exposes generic subscription semantics - it stays host-agnostic and non-React-specific +- its pure construction is separate from all runtime connections +- cleanup is paired with each connection boundary rather than collapsed into a single lifetime method ### Viable future `OverflowManager` @@ -150,21 +169,17 @@ The current convergence target is: ```ts interface OverflowManager { setOptions(options: Partial): void; - setContainer(element: HTMLElement | null): void; - setOverflowMenu(element: HTMLElement | null): void; + observe(container: HTMLElement): () => void; + attachOverflowMenu(element: HTMLElement): () => void; - addItem(item: OverflowItemEntry): void; - removeItem(itemId: string): void; - addDivider(divider: OverflowDividerEntry): void; - removeDivider(groupId: string): void; + registerItem(item: OverflowItemEntry): () => void; + registerDivider(divider: OverflowDividerEntry): () => void; update(): void; forceUpdate(): void; getSnapshot(): OverflowSnapshot; subscribe(listener: () => void): () => void; - - destroy(): void; } ``` @@ -183,6 +198,42 @@ where the returned handles support `update()` and `unregister()`. This remains a second-order improvement, not part of the immediate target. +### Lifecycle boundaries + +The desired lifecycle boundaries are: + +1. pure construction + +- `createOverflowManager()` +- no cleanup required + +2. configuration + +- `setOptions()` +- no external connection implied + +3. container observation + +- `observe(container)` returns observation cleanup + +4. menu attachment + +- `attachOverflowMenu(element)` returns detach cleanup + +5. item registration + +- `registerItem(item)` returns unregister cleanup + +6. divider registration + +- `registerDivider(divider)` returns unregister cleanup + +7. subscription + +- `subscribe(listener)` returns unsubscribe cleanup + +This is the corrected desired model. + ### 2. Bridge-facing snapshot The bridge-facing snapshot should be normalized and React-friendly. @@ -224,9 +275,9 @@ The desired `useOverflowContainer` shape is: - one long-lived manager instance per overflow container - `setOptions()` used for configuration changes -- `setContainer()` used for container attachment changes -- `setOverflowMenu()` used for menu attachment changes -- registration helpers exposed for item and divider wiring +- `observe(container)` used for observation setup with paired cleanup +- `attachOverflowMenu(element)` used for menu attachment with paired cleanup +- registration helpers exposed for item and divider wiring through paired cleanup boundaries - manager returned explicitly for downstream bridge usage A likely internal bridge-facing return shape is: @@ -244,6 +295,12 @@ type UseOverflowContainerReturn = { This keeps current registration ergonomics while making manager ownership explicit. +Internally, this implies a different lifecycle model from the current implementation: + +- the manager should not be created in render and then destroyed on unmount as if those were symmetrical operations +- instead, the manager should be treated as a pure inert object, and runtime connections should be established and torn down through paired boundaries +- `useOverflowContainer` should own the manager reference, but not collapse all cleanup into one terminal manager teardown path + ### 5. `Overflow.tsx` The desired `Overflow.tsx` state is: @@ -277,6 +334,8 @@ type OverflowContextValue = { This preserves the current practical API shape while keeping ownership explicit. +The important nuance is that these bridge registration helpers remain bridge ergonomics, not canonical lifecycle primitives. Under the corrected model they should be thin wrappers over engine APIs that already provide paired cleanup. + ### 7. Subscription helper layer The bridge should have one small internal selector-based subscription helper. @@ -317,7 +376,7 @@ The desired registration model in the viable state is: - public component APIs stay stable - registration helpers continue to exist for bridge ergonomics -- internal registration still uses add/remove semantics first +- internal registration should map onto paired engine registration boundaries first If later evidence shows metadata churn is still a first-order problem, then handle-based registration can be added as the next engine step. @@ -341,6 +400,7 @@ If the viable future `OverflowManager` existed, these bridge problems improve im 3. callback-only engine state access 4. the bridge being forced to act as the only stable state publisher 5. menu attachment awkwardness as a lifecycle concern +6. the need to model manager lifetime cleanup as one monolithic teardown path ## What still remains bridge-owned after that @@ -393,6 +453,12 @@ The current desired state is: 7. state-reading hooks that subscribe/select from manager-owned state 8. no required public component-level breaking changes for the viable transition +And, critically: + +9. pure manager construction should not itself require cleanup +10. every runtime connection should have paired local cleanup +11. the normal desired design should not depend on a monolithic `destroy()` to compensate for blurred lifecycle boundaries + This is the current basis for implementation discussions. ## Exit condition diff --git a/packages/react-components/react-overflow/library/src/overflowContext.ts b/packages/react-components/react-overflow/library/src/overflowContext.ts index 2e3dd6c2b7f7a0..1f8a8c8e615349 100644 --- a/packages/react-components/react-overflow/library/src/overflowContext.ts +++ b/packages/react-components/react-overflow/library/src/overflowContext.ts @@ -20,19 +20,13 @@ const defaultSnapshot: OverflowSnapshot = { const defaultManager: OverflowManager = { setOptions: () => undefined, - setContainer: () => undefined, - setOverflowMenu: () => undefined, - observe: () => undefined, - disconnect: () => undefined, - destroy: () => undefined, - addItem: () => undefined, + observe: () => () => undefined, + registerItem: () => () => undefined, removeItem: () => undefined, update: () => undefined, forceUpdate: () => undefined, - addOverflowMenu: () => undefined, - addDivider: () => undefined, - removeDivider: () => undefined, - removeOverflowMenu: () => undefined, + attachOverflowMenu: () => () => undefined, + registerDivider: () => () => undefined, getSnapshot: () => defaultSnapshot, subscribe: () => () => undefined, }; diff --git a/packages/react-components/react-overflow/library/src/types.ts b/packages/react-components/react-overflow/library/src/types.ts index 80cae1bf9ca30f..24e89007bc1a1a 100644 --- a/packages/react-components/react-overflow/library/src/types.ts +++ b/packages/react-components/react-overflow/library/src/types.ts @@ -10,7 +10,7 @@ export interface UseOverflowContainerReturn /** * Ref to apply to the container that will overflow */ - containerRef: React.RefObject; + containerRef: React.RefCallback; /** * Canonical overflow manager for the current container. diff --git a/packages/react-components/react-overflow/library/src/useOverflowContainer.test.ts b/packages/react-components/react-overflow/library/src/useOverflowContainer.test.ts index 66dfd604e31d36..5eb7c44fdfb127 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowContainer.test.ts +++ b/packages/react-components/react-overflow/library/src/useOverflowContainer.test.ts @@ -1,29 +1,22 @@ -import type * as React from 'react'; import type { OverflowAxis, OverflowManager } from '@fluentui/priority-overflow'; import { createOverflowManager } from '@fluentui/priority-overflow'; import { useOverflowContainer } from './useOverflowContainer'; -import { renderHook } from '@testing-library/react-hooks'; +import { act, renderHook } from '@testing-library/react-hooks'; jest.mock('@fluentui/priority-overflow'); const mockOverflowManager = (options: Partial = {}) => { const defaultMock: OverflowManager = { - addItem: jest.fn(), - addOverflowMenu: jest.fn(), - disconnect: jest.fn(), - destroy: jest.fn(), + attachOverflowMenu: jest.fn(() => () => undefined), forceUpdate: jest.fn(), getSnapshot: jest.fn(() => ({ hasOverflow: false, overflowCount: 0, itemVisibility: {}, groupVisibility: {} })), - observe: jest.fn(), + observe: jest.fn(() => () => undefined), + registerDivider: jest.fn(() => () => undefined), + registerItem: jest.fn(() => () => undefined), removeItem: jest.fn(), - removeOverflowMenu: jest.fn(), - setContainer: jest.fn(), setOptions: jest.fn(), - setOverflowMenu: jest.fn(), subscribe: jest.fn(() => () => undefined), update: jest.fn(), - addDivider: jest.fn(), - removeDivider: jest.fn(), }; (createOverflowManager as jest.Mock).mockReturnValue({ ...defaultMock, @@ -39,8 +32,8 @@ describe('useOverflowContainer', () => { }); it('should add to overflow manager when registering item', () => { - const addItemMock = jest.fn(); - mockOverflowManager({ addItem: addItemMock }); + const registerItemMock = jest.fn(() => () => undefined); + mockOverflowManager({ registerItem: registerItemMock }); const { result } = renderHook(() => useOverflowContainer(() => undefined, { onUpdateItemVisibility: () => undefined }), ); @@ -48,13 +41,14 @@ describe('useOverflowContainer', () => { const overflowItem = { element: document.createElement('div'), id: 'test', priority: 0, groupId: '0' }; result.current.registerItem(overflowItem); - expect(addItemMock).toHaveBeenCalledTimes(1); - expect(addItemMock).toHaveBeenCalledWith(overflowItem); + expect(registerItemMock).toHaveBeenCalledTimes(1); + expect(registerItemMock).toHaveBeenCalledWith(overflowItem); }); it('should return cleanup when registering item', () => { - const removeItemMock = jest.fn(); - mockOverflowManager({ removeItem: removeItemMock }); + const deregisterItemMock = jest.fn(); + const registerItemMock = jest.fn(() => deregisterItemMock); + mockOverflowManager({ registerItem: registerItemMock }); const { result } = renderHook(() => useOverflowContainer(() => undefined, { onUpdateItemVisibility: () => undefined }), ); @@ -63,8 +57,7 @@ describe('useOverflowContainer', () => { const deregister = result.current.registerItem(overflowItem); deregister(); - expect(removeItemMock).toHaveBeenCalledTimes(1); - expect(removeItemMock).toHaveBeenCalledWith(overflowItem.id); + expect(deregisterItemMock).toHaveBeenCalledTimes(1); }); it('should call observe with default options', () => { @@ -73,22 +66,14 @@ describe('useOverflowContainer', () => { const { result, rerender } = renderHook(() => { return useOverflowContainer(() => undefined, { onUpdateItemVisibility: () => undefined }); }); - // eslint-disable-next-line @typescript-eslint/no-deprecated - (result.current.containerRef as React.MutableRefObject).current = document.createElement('div'); + act(() => { + result.current.containerRef(document.createElement('div')); + }); rerender(); expect(observeMock).toHaveBeenCalledTimes(1); expect(observeMock.mock.calls[0]).toMatchInlineSnapshot(` Array [
, - Object { - "hasHiddenItems": false, - "minimumVisible": 0, - "onUpdateItemVisibility": [Function], - "onUpdateOverflow": [Function], - "overflowAxis": "horizontal", - "overflowDirection": "end", - "padding": 10, - }, ] `); }); @@ -110,7 +95,13 @@ describe('useOverflowContainer', () => { expect(createOverflowManager).toHaveBeenCalledTimes(1); expect(setOptionsMock).toHaveBeenCalledWith( expect.objectContaining({ + hasHiddenItems: false, + minimumVisible: 0, + onUpdateItemVisibility: expect.any(Function), + onUpdateOverflow: expect.any(Function), overflowAxis: 'vertical', + overflowDirection: 'end', + padding: 10, }), ); }); diff --git a/packages/react-components/react-overflow/library/src/useOverflowContainer.ts b/packages/react-components/react-overflow/library/src/useOverflowContainer.ts index e2fa09ea5f7f0d..1bdf6e3da5e0af 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowContainer.ts +++ b/packages/react-components/react-overflow/library/src/useOverflowContainer.ts @@ -14,7 +14,7 @@ import type { OverflowManager, ObserveOptions, } from '@fluentui/priority-overflow'; -import { canUseDOM, useEventCallback, useFirstMount, useIsomorphicLayoutEffect } from '@fluentui/react-utilities'; +import { canUseDOM, useEventCallback, useIsomorphicLayoutEffect } from '@fluentui/react-utilities'; import type { UseOverflowContainerReturn } from './types'; import { DATA_OVERFLOWING, DATA_OVERFLOW_DIVIDER, DATA_OVERFLOW_ITEM, DATA_OVERFLOW_MENU } from './constants'; @@ -65,70 +65,37 @@ export const useOverflowContainer = ( ], ); - const managerOptions = React.useMemo( - () => ({ - overflowAxis, - overflowDirection, - padding, - minimumVisible, - hasHiddenItems, - }), - [minimumVisible, overflowAxis, overflowDirection, padding, hasHiddenItems], - ); + const [container, setContainer] = React.useState(null); - const firstMount = useFirstMount(); - const hasObservedRef = React.useRef(false); - - // DOM ref to the overflow container element - const containerRef = React.useRef(null); + const containerRef = React.useCallback>(node => { + setContainer(node); + }, []); const overflowManager = React.useState(() => canUseDOM() ? createOverflowManager() : null, )[0]; - // Initialize the manager with the full observation contract once, when the container ref is first available. useIsomorphicLayoutEffect(() => { - if (!hasObservedRef.current && containerRef.current) { - overflowManager?.observe(containerRef.current, observeOptions); - hasObservedRef.current = true; - } - }, [overflowManager, observeOptions, firstMount]); + overflowManager?.setOptions(observeOptions); + }, [observeOptions, overflowManager]); - // After first mount, option changes should reconfigure the same manager. useIsomorphicLayoutEffect(() => { - if (!overflowManager || firstMount) { + if (!overflowManager || !container) { return; } - overflowManager.setOptions(managerOptions); - }, [firstMount, overflowManager, managerOptions]); - - // Keep the attached container current across renders after mount. - useIsomorphicLayoutEffect(() => { - if (!overflowManager || !hasObservedRef.current || firstMount) { - return; - } - - overflowManager.setContainer(containerRef.current); - }); - - /* Fully destroy the manager on unmount */ - React.useEffect( - () => () => { - overflowManager?.destroy(); - }, - [overflowManager], - ); + return overflowManager.observe(container); + }, [container, overflowManager]); const registerItem = React.useCallback( (item: OverflowItemEntry) => { - overflowManager?.addItem(item); + const deregisterItem = overflowManager?.registerItem(item) ?? noop; item.element.setAttribute(DATA_OVERFLOW_ITEM, ''); return () => { item.element.removeAttribute(DATA_OVERFLOWING); item.element.removeAttribute(DATA_OVERFLOW_ITEM); - overflowManager?.removeItem(item.id); + deregisterItem(); }; }, [overflowManager], @@ -137,11 +104,11 @@ export const useOverflowContainer = ( const registerDivider = React.useCallback( (divider: OverflowDividerEntry) => { const el = divider.element; - overflowManager?.addDivider(divider); + const deregisterDivider = overflowManager?.registerDivider(divider) ?? noop; el.setAttribute(DATA_OVERFLOW_DIVIDER, ''); return () => { - divider.groupId && overflowManager?.removeDivider(divider.groupId); + deregisterDivider(); el.removeAttribute(DATA_OVERFLOW_DIVIDER); }; }, @@ -150,11 +117,11 @@ export const useOverflowContainer = ( const registerOverflowMenu = React.useCallback( (el: HTMLElement) => { - overflowManager?.setOverflowMenu(el); + const detachOverflowMenu = overflowManager?.attachOverflowMenu(el) ?? noop; el.setAttribute(DATA_OVERFLOW_MENU, ''); return () => { - overflowManager?.setOverflowMenu(null); + detachOverflowMenu(); el.removeAttribute(DATA_OVERFLOW_MENU); }; }, From 5b89535a4abec575391b99304e8e0dba6bf83fca Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Mon, 25 May 2026 22:41:53 +0200 Subject: [PATCH 05/14] refine react-overflow bridge subscriptions --- .../library/docs/overflow-algorithm.md | 117 +++-- .../library/docs/overflow-northstar.md | 475 ++---------------- .../docs/react-overflow-react-bridge.md | 115 ++--- .../library/etc/react-overflow.api.md | 4 +- .../library/src/Overflow.cy.tsx | 4 +- .../library/src/components/Overflow.tsx | 55 +- .../library/src/overflowContext.ts | 11 +- .../library/src/useOverflowItem.test.tsx | 16 +- .../src/useOverflowVisibility.test.tsx | 2 - 9 files changed, 229 insertions(+), 570 deletions(-) diff --git a/packages/react-components/react-overflow/library/docs/overflow-algorithm.md b/packages/react-components/react-overflow/library/docs/overflow-algorithm.md index 0f5b2e9cb7e401..6fab5ad9b525c4 100644 --- a/packages/react-components/react-overflow/library/docs/overflow-algorithm.md +++ b/packages/react-components/react-overflow/library/docs/overflow-algorithm.md @@ -15,7 +15,7 @@ For open design directions, refactor ideas, and unresolved questions, see `docs/ ## One-sentence model -The engine keeps two priority queues of item ids, measures the container and registered elements, then repeatedly moves items between the visible and invisible queues until occupied size fits within available size. +The engine keeps two priority queues of item ids, measures the observed container and registered elements, then repeatedly moves items between the visible and invisible queues until occupied size fits within available size while publishing a canonical snapshot and optional callbacks. ## Core concepts @@ -53,15 +53,16 @@ The comparator uses three rules: ```mermaid flowchart TD - A[Register container and options] --> B[Register items, menu, dividers] - B --> C[ResizeObserver or manual update] - C --> D[Microtask debounce] - D --> E[Read container client size] - E --> F[Read item, divider, and menu sizes with cache] - F --> G[Rebalance queues by priority and fit] - G --> H[Mark items and dividers overflowing or visible] - H --> I[Dispatch visibleItems, invisibleItems, groupVisibility] - I --> J[Consumer layer applies visibility changes] + A[Create manager] --> B[setOptions] + B --> C[Register items, menu, dividers] + C --> D[observe container] + D --> E[ResizeObserver or manual update] + E --> F[Microtask debounce] + F --> G[Read container client size] + G --> H[Read item, divider, and menu sizes with cache] + H --> I[Rebalance queues by priority and fit] + I --> J[Update snapshot and invoke callbacks] + J --> K[Subscribers and wrapper react] ``` ## Lifecycle @@ -69,12 +70,13 @@ flowchart TD ```mermaid stateDiagram-v2 [*] --> Idle - Idle --> Observing: observe(container, options) - Observing --> Dirty: addItem / removeItem / resize / manual update + Idle --> Configured: setOptions / registerItem / registerDivider / attachOverflowMenu + Configured --> Observing: observe(container) + Observing --> Dirty: register or unregister / resize / manual update Dirty --> Scheduled: update() Scheduled --> Processing: microtask runs forceUpdate() - Processing --> Observing: state stabilized and callback dispatched - Observing --> Idle: disconnect() + Processing --> Observing: snapshot and callbacks updated + Observing --> Configured: observation cleanup ``` ## Detailed lifecycle @@ -91,25 +93,51 @@ stateDiagram-v2 At this point, nothing is observed and no DOM reads happen. -### 2. Observation starts +### 2. Configuration -`observe(container, options)`: +`setOptions(options)` mutates the live options object independently from observation. + +That means configuration and attachment are separate concerns: + +- options can change without recreating observation +- callback handlers can be replaced incrementally +- observation does not implicitly own all other runtime relationships + +The engine still keeps `onUpdateOverflow` and `onUpdateItemVisibility` in its options object, but they are no longer the only readable output channel because the manager also maintains a canonical `OverflowSnapshot`. + +### 3. Runtime connections + +The manager uses paired setup and cleanup boundaries for its runtime relationships. + +`observe(container)`: -- copies user options into the live options object -- moves all already-known items into the visible queue - stores the container reference -- starts a `ResizeObserver` on the container +- reconnects the `ResizeObserver` +- enqueues any already-known items that are not yet in either queue +- schedules an update +- returns a cleanup function that detaches observation and resets the current snapshot to empty + +`registerItem(item)`: + +- inserts the item into the registry +- updates group membership when `groupId` exists +- enqueues the item immediately if observation is already active +- schedules an update +- returns a cleanup function that removes the item from queues and registries -Important: the engine does not discover DOM nodes by itself. Items must be registered explicitly. +`attachOverflowMenu(element)`: -### 3. Registration churn +- stores the overflow menu reference +- schedules an update +- returns a cleanup function that detaches that same menu element -`addItem`, `removeItem`, `addOverflowMenu`, and `addDivider` mutate the engine model. +`registerDivider(divider)`: -- `addItem` inserts an item into the registry and queue, updates group membership, and schedules `update()` -- `removeItem` removes it from both queues and schedules `update()` -- `addOverflowMenu` only stores a reference; the menu enters size calculations later -- `addDivider` stores a divider keyed by `groupId` +- stores the divider by `groupId` +- sets the divider's group attribute +- returns a cleanup function that removes the divider registration + +Important: the engine still exposes `removeItem()` as a lower-level removal helper, but the preferred lifecycle model is the cleanup returned from `registerItem()`. ### 4. Update scheduling @@ -132,7 +160,7 @@ The algorithm: 2. Reads available size as `container.clientWidth` or `clientHeight` minus `padding`. 3. Repairs priority ordering if the best hidden item outranks the worst visible item. 4. Runs two show/hide rounds to stabilize the state. -5. Dispatches only if queue tops changed, or if `forceDispatch` was set. +5. Rebuilds the canonical snapshot and notifies listeners when queue tops changed, or when `forceDispatch` was set. The two-round design matters when a new item is added as visible by default and the first pass has not yet settled into the best visible/invisible split. @@ -392,6 +420,32 @@ In practical terms, the engine is usually fine for toolbar-sized collections. Th - many groups and active dividers - updates happen while layout is already dirty +## Outputs + +The engine now has two output channels. + +### 1. Canonical snapshot and subscription + +Every dispatched pass rebuilds: + +- `hasOverflow` +- `overflowCount` +- `itemVisibility` +- `groupVisibility` + +Subscribers registered through `subscribe(listener)` are notified after the snapshot is updated. + +This is the stable readable state channel used by the React selector hooks. + +### 2. Optional callbacks + +The manager still invokes: + +- `onUpdateItemVisibility` +- `onUpdateOverflow` + +These remain useful for imperative side effects such as applying `data-overflowing` attributes, but they are no longer the only way to observe engine state. + ## Scenario atlas This section turns the abstract algorithm into concrete situations. The goal is to answer two questions for each case: @@ -411,13 +465,14 @@ sequenceDiagram participant Browser as Browser App->>Wrapper: mount container and items + Wrapper->>Engine: setOptions(...) + Wrapper->>Engine: registerItem(A..N) Wrapper->>Engine: observe(container) - Wrapper->>Engine: addItem(A..N) Engine->>Engine: queue all items as visible Browser-->>Engine: ResizeObserver callback Engine->>Browser: read container and item sizes Engine->>Engine: occupiedSize <= availableSize - Engine-->>Wrapper: dispatch all visible + Engine-->>Wrapper: update snapshot and callbacks Wrapper->>Browser: no data-overflowing writes needed ``` @@ -773,8 +828,8 @@ sequenceDiagram participant Engine as Overflow manager participant Microtask as Microtask queue - App->>Engine: addItem(A) - App->>Engine: addItem(B) + App->>Engine: registerItem(A) + App->>Engine: registerItem(B) App->>Engine: removeItem(C) App->>Engine: update() Engine->>Microtask: schedule one debounced run diff --git a/packages/react-components/react-overflow/library/docs/overflow-northstar.md b/packages/react-components/react-overflow/library/docs/overflow-northstar.md index f8dfa8eaccfc04..639dfa8437aadd 100644 --- a/packages/react-components/react-overflow/library/docs/overflow-northstar.md +++ b/packages/react-components/react-overflow/library/docs/overflow-northstar.md @@ -1,470 +1,103 @@ # Overflow Northstar -This document is the temporary working document for future overflow changes. +This document now tracks only the remaining delta after the lifecycle-boundary refactor. It is not the source of truth for current behavior. Current behavior is described by: - `overflow-algorithm.md` for the engine - `react-overflow-react-bridge.md` for the React bridge -This file exists to capture: +The long-term goal is still to delete this document once the remaining open decisions have either been resolved or absorbed into the two specs. -- the current architecture decisions -- the desired future state -- the remaining narrow decisions -- the basis for implementation discussions +## What is complete -The long-term goal is to delete this document once its useful conclusions have been absorbed into the two specs. +The viable refactor that originally motivated this document is now implemented. -## Why this work exists +Completed pieces: -This document is not proposing refactoring for its own sake. +1. the engine contract is now centered on a long-lived `OverflowManager` +2. pure construction is separated from runtime connections +3. `setOptions()` is independent from observation +4. `observe(container)` returns cleanup instead of coupling configuration to observation +5. `attachOverflowMenu(element)` returns cleanup +6. `registerItem(item)` and `registerDivider(divider)` return cleanup +7. the engine publishes canonical readable state through `getSnapshot()` and `subscribe()` +8. `useOverflowContainer()` now keeps one manager instance per container and configures it incrementally +9. selector-based state-reading hooks now subscribe to manager-owned snapshot state +10. the component-level public API remains stable +11. `Overflow.tsx` and `OverflowContext` no longer republish mirrored visibility state through the provider -It exists because the current engine and bridge specs already point to concrete problems that the future design should address. +Those conclusions are now part of current truth and should be read primarily from the engine and bridge specs. -### What the engine spec tells us +## Current state summary -The engine spec shows that the current engine: +Today the system is best understood like this: -- couples configuration to observation through `observe(container, options)` -- recomputes aggregate occupied size repeatedly during stabilization -- exposes its results primarily through callback flow instead of stable readable state -- treats registration as add/remove lifecycle rather than as a long-lived controller relationship +1. the engine owns canonical overflow truth +2. the bridge owns React integration and ergonomics +3. the engine contract now has paired cleanup boundaries instead of a monolithic teardown model +4. the bridge has adopted manager subscription as its primary state-reading path +5. the provider layer is now manager-and-registration only, while selector hooks remain the external-store readers -Those are not speculative complaints. They are direct consequences of the current engine contract and algorithm shape described in `overflow-algorithm.md`. +## Remaining open questions -### What the bridge spec tells us +The remaining questions are narrower than the original refactor. -The bridge spec shows that the current bridge: +### 1. Should the bridge keep the menu follow-up update path? -- mirrors engine state into React-owned state -- rebuilds visibility maps on every engine update -- republishes those maps through context -- recreates the engine when options change after first mount -- sometimes causes follow-up engine work through React effects, especially around menu handling +`useOverflowMenu()` still calls `updateOverflow()` after the menu becomes layout-participating. -Those are not aesthetic concerns. They are the concrete bridge costs and ownership problems described in `react-overflow-react-bridge.md`. +This is understandable and correct today, but it remains a second-phase bridge feedback loop that may or may not deserve further simplification. -### What the northstar is trying to solve +### 2. Should lower-level removal helpers stay public on the engine surface? -The northstar is therefore trying to solve three things that are already documented in the current specs: +The current implementation still exposes `removeItem()`. -1. the engine contract is not stable enough for the bridge to hold onto comfortably -2. the bridge is compensating for engine contract weakness by re-owning and republishing engine state -3. React integration is paying more churn than it should because ownership and lifecycle boundaries are blurry +That is compatible with the cleanup-boundary model, but it is not part of the minimal desired shape that originally motivated the northstar. The remaining question is whether it should remain as a low-level escape hatch or be folded away later. -That is why the desired state in this document is centered on: +### 3. Do ideal registration handles still matter? -- a long-lived, incrementally configurable, readable, and subscribable `OverflowManager` -- a bridge that remains the stable React boundary without re-owning canonical overflow state -- manager-in-context plus selector-based bridge subscriptions +The viable cleanup-returning API is in place. -### Success condition - -This work is only justified if it removes or materially reduces the specific problems already described in the current specs. - -If it does not improve those concrete problems, then it is not a worthwhile migration or refactor. - -## Current conclusions - -The current research baseline leads to these conclusions: - -1. the engine has real cost, especially repeated aggregate measurement and layout-affecting visibility changes -2. the React bridge has real cost, especially mirrored state, map rebuilding, and React subscription churn -3. the bridge should already be the stable boundary for React, but the current engine API may not give it a strong enough contract to play that role well -4. future work should start from clearly stated ownership, subscription, lifecycle, and measurement questions rather than from broad dissatisfaction - -## Decision log - -### 1. Keep the three-document structure - -The current document structure is intentionally: - -- `overflow-algorithm.md` for the engine spec -- `react-overflow-react-bridge.md` for the bridge spec -- `overflow-northstar.md` for everything exploratory and temporary - -### 2. Keep the bridge as the intended stable React boundary - -The current framing is not that React necessarily needs a new permanent adapter layer. - -The current framing is that the existing bridge is supposed to be that stable boundary already, and the engine contract may be underdesigned for that role. - -### 3. Evolve `OverflowManager` rather than invent a new top-level abstraction name - -The target is a future shape of `OverflowManager`, not a separately named controller. - -### 4. Store-like behavior is acceptable in the engine if it remains generic - -Readable state and generic subscription are acceptable engine concerns as long as they remain host-agnostic and do not make the engine React-shaped. - -### 5. Pure construction and paired cleanup are required - -The manager should be created in a pure way. - -That means construction itself should not require cleanup. - -Cleanup should be attached to the specific runtime relationships that are created later: - -- observation -- subscription -- item registration -- divider registration -- menu attachment -- container attachment - -The normal desired design should not rely on a monolithic `destroy()` to compensate for blurred lifecycle boundaries. - -### 6. Converge on the viable engine contract first - -The current target is the viable future `OverflowManager`, not the ideal one. - -The viable target includes: - -- `setOptions()` -- `observe(container)` returning cleanup -- `attachOverflowMenu(element)` returning cleanup -- `registerItem(item)` returning cleanup -- `registerDivider(divider)` returning cleanup -- `getSnapshot()` -- `subscribe()` - -### 7. Handle-based registration is ideal, but not required yet - -`registerItem(...)->handle.update()/unregister()` and its divider equivalent remain part of the ideal engine surface, but they are not required for the first viable improved state. - -### 8. Engine contract work comes before deeper bridge redesign - -The next phase should assume that improving the engine contract comes first. - -Only after that should we decide how much of the remaining bridge complexity still justifies deeper bridge redesign or ideal-surface expansion. - -### 9. The viable transition should be non-breaking at the component level - -The viable engine and bridge redesign should be treated as non-breaking at the component level. - -Breaking changes should only enter the conversation later if the team intentionally chooses to redesign the public React ergonomics, not as a side effect of fixing the engine contract. - -## Desired state - -This section captures the current end-state target as a single coherent model. - -### 1. Engine - -The desired engine state is: - -- `OverflowManager` remains the core engine abstraction -- it is long-lived rather than routinely reconstructed -- it is incrementally configurable -- it exposes canonical readable overflow state -- it exposes generic subscription semantics -- it stays host-agnostic and non-React-specific -- its pure construction is separate from all runtime connections -- cleanup is paired with each connection boundary rather than collapsed into a single lifetime method - -### Viable future `OverflowManager` - -The current convergence target is: - -```ts -interface OverflowManager { - setOptions(options: Partial): void; - observe(container: HTMLElement): () => void; - attachOverflowMenu(element: HTMLElement): () => void; - - registerItem(item: OverflowItemEntry): () => void; - registerDivider(divider: OverflowDividerEntry): () => void; - - update(): void; - forceUpdate(): void; - - getSnapshot(): OverflowSnapshot; - subscribe(listener: () => void): () => void; -} -``` - -This is the smallest contract that materially changes the current bridge behavior rather than merely making the engine look cleaner. - -### Ideal future `OverflowManager` - -The ideal version may later add: +The remaining question is whether handle-based registration such as: ```ts -registerItem(item: OverflowItemEntry): OverflowItemHandle; -registerDivider(divider: OverflowDividerEntry): OverflowDividerHandle; +registerItem(item): OverflowItemHandle; +registerDivider(divider): OverflowDividerHandle; ``` -where the returned handles support `update()` and `unregister()`. - -This remains a second-order improvement, not part of the immediate target. - -### Lifecycle boundaries - -The desired lifecycle boundaries are: - -1. pure construction - -- `createOverflowManager()` -- no cleanup required - -2. configuration - -- `setOptions()` -- no external connection implied - -3. container observation - -- `observe(container)` returns observation cleanup - -4. menu attachment - -- `attachOverflowMenu(element)` returns detach cleanup - -5. item registration - -- `registerItem(item)` returns unregister cleanup - -6. divider registration - -- `registerDivider(divider)` returns unregister cleanup - -7. subscription - -- `subscribe(listener)` returns unsubscribe cleanup - -This is the corrected desired model. - -### 2. Bridge-facing snapshot - -The bridge-facing snapshot should be normalized and React-friendly. - -```ts -type OverflowSnapshot = { - hasOverflow: boolean; - overflowCount: number; - itemVisibility: Record; - groupVisibility: Record; -}; -``` +still buys enough to justify another engine step. Current recommendation: -- `hasOverflow` is useful and cheap -- `overflowCount` should be first-class because the engine already knows invisible item count -- `itemVisibility` and `groupVisibility` are the most stable bridge-facing structures - -By default, raw `visibleItems` and `invisibleItems` should not be treated as the bridge-facing canonical snapshot shape. - -### 3. Bridge ownership model - -The desired bridge state is: - -- the bridge remains the stable React boundary -- the bridge does not own canonical overflow state -- the bridge adapts engine-owned state into React usage patterns -- the bridge stops compensating for engine lifecycle weakness - -In other words: - -- engine owns overflow truth -- bridge owns React integration and ergonomics - -### 4. `useOverflowContainer` - -The desired `useOverflowContainer` shape is: - -- one long-lived manager instance per overflow container -- `setOptions()` used for configuration changes -- `observe(container)` used for observation setup with paired cleanup -- `attachOverflowMenu(element)` used for menu attachment with paired cleanup -- registration helpers exposed for item and divider wiring through paired cleanup boundaries -- manager returned explicitly for downstream bridge usage - -A likely internal bridge-facing return shape is: - -```ts -type UseOverflowContainerReturn = { - containerRef: React.RefObject; - manager: OverflowManager; - registerItem(item: OverflowItemEntry): () => void; - registerDivider(divider: OverflowDividerEntry): () => void; - registerOverflowMenu(element: HTMLElement): () => void; - updateOverflow(): void; -}; -``` - -This keeps current registration ergonomics while making manager ownership explicit. - -Internally, this implies a different lifecycle model from the current implementation: - -- the manager should not be created in render and then destroyed on unmount as if those were symmetrical operations -- instead, the manager should be treated as a pure inert object, and runtime connections should be established and torn down through paired boundaries -- `useOverflowContainer` should own the manager reference, but not collapse all cleanup into one terminal manager teardown path - -### 5. `Overflow.tsx` - -The desired `Overflow.tsx` state is: - -- thin wrapper around manager lifecycle and context wiring -- no primary mirrored overflow state ownership -- still exposes `onOverflowChange` -- still composes refs and classes -- still provides React-friendly composition surface - -So `Overflow.tsx` should remain important, but it should stop behaving like a second canonical state owner. - -### 6. Context - -The desired context state is: - -- manager-in-context, not snapshot-in-context as the primary model -- registration helpers remain in context for migration-friendly ergonomics - -The target shape is: - -```ts -type OverflowContextValue = { - manager: OverflowManager; - registerItem(item: OverflowItemEntry): () => void; - registerDivider(divider: OverflowDividerEntry): () => void; - registerOverflowMenu(element: HTMLElement): () => void; - updateOverflow(): void; -}; -``` - -This preserves the current practical API shape while keeping ownership explicit. - -The important nuance is that these bridge registration helpers remain bridge ergonomics, not canonical lifecycle primitives. Under the corrected model they should be thin wrappers over engine APIs that already provide paired cleanup. - -### 7. Subscription helper layer - -The bridge should have one small internal selector-based subscription helper. - -Conceptually: - -```ts -function useOverflowSelector(selector: (snapshot: OverflowSnapshot) => T): T; -``` - -Its job is to: - -1. obtain the manager from context -2. subscribe to the manager -3. read `getSnapshot()` -4. apply the selector -5. let React re-render when the selected value changes - -Current recommendation on equality: - -- keep selector results narrow enough that `Object.is`-style stability is usually sufficient -- avoid a broad custom equality layer unless profiling proves it is necessary - -### 8. State-reading hooks - -The desired hook model is: - -- `useOverflowVisibility()` selects full visibility structures from the manager snapshot -- `useIsOverflowItemVisible(id)` selects one item visibility flag -- `useIsOverflowGroupVisible(id)` selects one group visibility value -- `useOverflowCount()` reads a first-class `overflowCount` from the manager snapshot - -These hooks should stop reading bridge-owned mirrored context state. - -### 9. Registration hooks and components - -The desired registration model in the viable state is: - -- public component APIs stay stable -- registration helpers continue to exist for bridge ergonomics -- internal registration should map onto paired engine registration boundaries first - -If later evidence shows metadata churn is still a first-order problem, then handle-based registration can be added as the next engine step. - -### 10. Public API stance - -The desired near-term public API stance is: - -- no required component-level breaking changes -- `Overflow`, `OverflowItem`, and `OverflowDivider` remain stable -- current props remain stable -- current children-based composition remains stable - -Breaking changes are only justified later if the team intentionally decides to redesign public ergonomics, not as a side effect of fixing engine and bridge ownership. - -## What improves immediately with the viable engine contract - -If the viable future `OverflowManager` existed, these bridge problems improve immediately: +- not yet +- only revisit this if metadata churn becomes a meaningful remaining cost -1. manager recreation on option changes -2. attachment and configuration being entangled -3. callback-only engine state access -4. the bridge being forced to act as the only stable state publisher -5. menu attachment awkwardness as a lifecycle concern -6. the need to model manager lifetime cleanup as one monolithic teardown path +### 4. Do we need custom selector equality? -## What still remains bridge-owned after that - -Even under the viable engine contract, these still remain bridge-side questions: - -- final React subscription granularity -- context propagation strategy -- whether broad snapshot reads are acceptable -- whether menu behavior still needs any secondary bridge modeling -- whether a future hook-first public API is worth pursuing - -These are now secondary questions rather than architectural blockers. - -## Remaining narrow decisions - -At this point, the remaining questions are narrow and implementation-shaped. - -### 1. Do we need custom selector equality? - -Current recommendation: +Current recommendation remains: - not by default - keep selections narrow first - only add broader equality support if profiling proves it necessary -### 2. Should raw `visibleItems` / `invisibleItems` remain available anywhere? - -Current recommendation: - -- not as the bridge-facing canonical snapshot shape -- they may still exist as internal engine data or secondary outputs if later needed - -### 3. Does ideal handle-based registration still matter? - -Current recommendation: - -- yes, but explicitly second-order -- only pursue it if metadata churn remains a meaningful bridge cost after the viable engine contract is in place - -## Final current conclusion - -The current desired state is: - -1. a long-lived, incrementally configurable, readable, and subscribable `OverflowManager` -2. a normalized bridge-facing snapshot containing `hasOverflow`, `overflowCount`, `itemVisibility`, and `groupVisibility` -3. a bridge that remains the stable React boundary without re-owning canonical overflow state -4. `useOverflowContainer` as the stable owner of one manager instance -5. manager-in-context with registration helpers -6. a small bridge subscription helper layer -7. state-reading hooks that subscribe/select from manager-owned state -8. no required public component-level breaking changes for the viable transition +## What no longer belongs here -And, critically: +The following are no longer northstar-only conclusions because they are now implemented: -9. pure manager construction should not itself require cleanup -10. every runtime connection should have paired local cleanup -11. the normal desired design should not depend on a monolithic `destroy()` to compensate for blurred lifecycle boundaries +- pure manager construction should not itself require cleanup +- runtime connections should have paired local cleanup +- the manager should expose canonical snapshot state and generic subscription +- the bridge should use one long-lived manager per container +- state-reading hooks should subscribe to manager-owned state -This is the current basis for implementation discussions. +Those points now belong in the engine and bridge specs, not here. ## Exit condition -This document should shrink over time. +This file should disappear once the remaining questions above are either: -When a conclusion becomes stable enough to be treated as truth, it should move into either the engine spec or the bridge spec. +1. resolved in code and moved into the two specs, or +2. intentionally rejected as unnecessary follow-up work -The success condition is that this document eventually disappears because the two specs are good enough to stand on their own. +At that point, the engine spec and bridge spec should be able to stand on their own without a third design ledger. diff --git a/packages/react-components/react-overflow/library/docs/react-overflow-react-bridge.md b/packages/react-components/react-overflow/library/docs/react-overflow-react-bridge.md index b4eb27782f2651..395db96773b1df 100644 --- a/packages/react-components/react-overflow/library/docs/react-overflow-react-bridge.md +++ b/packages/react-components/react-overflow/library/docs/react-overflow-react-bridge.md @@ -18,18 +18,18 @@ That mismatch is exactly where some of the current awkwardness and extra cost co ## One-sentence model -The React package turns an imperative DOM overflow manager into a React-facing API by registering DOM nodes through refs, mirroring engine state into React state, and redistributing that state through context selectors. +The React package turns an imperative DOM overflow manager into a React-facing API by wiring DOM nodes through refs, configuring one long-lived manager per container, exposing the manager through context, and reading manager-owned snapshot state through narrow selector hooks. ## Why the bridge is not ideal The bridge works, but it has structural costs. -The main ones are: +The main remaining ones are: -1. it duplicates state that already exists inside the engine -2. it converts imperative updates into React re-renders -3. it sometimes triggers follow-up engine work in response to React state changes -4. it relies on child-cloning and ref plumbing that constrain the API surface +1. the bridge still converts engine updates into React re-renders for subscribed consumers +2. `useOverflowMenu()` can still trigger a follow-up engine update when the menu first participates in layout +3. the wrapper still relies on child-cloning and ref plumbing that constrain the API surface +4. the selector-hook path still depends on `useSyncExternalStore` This document is intentionally descriptive. Its job is to explain the current bridge clearly enough that later design discussions can start from a shared understanding instead of from vague dissatisfaction. @@ -38,14 +38,13 @@ This document is intentionally descriptive. Its job is to explain the current br ```mermaid flowchart TD A[React renders Overflow tree] --> B[Refs register DOM nodes with engine] - B --> C[Engine measures and computes visibility] - C --> D[Engine writes data-overflowing attributes] - C --> E[Engine emits visibleItems and invisibleItems] - E --> F[Overflow.tsx rebuilds itemVisibility and groupVisibility maps] - F --> G[React setState] - G --> H[Context provider value changes] - H --> I[Selector hooks re-render interested components] - I --> J[Some hooks trigger follow-up engine update calls] + B --> C[useOverflowContainer configures manager and observation] + C --> D[Engine measures and computes visibility] + D --> E[Engine updates snapshot and item visibility callback] + E --> F[Selector hooks subscribe via useSyncExternalStore] + E --> G[Overflow.tsx effect subscription drives onOverflowChange] + F --> H[Selector hooks re-render interested components] + H --> I[useOverflowMenu may trigger follow-up updateOverflow] ``` ## Current bridge architecture @@ -62,57 +61,44 @@ That means the engine lifecycle depends on: This is pragmatic, but it means the bridge is not a pure data model. It is tightly coupled to commit timing. -### 2. State mirroring in `Overflow.tsx` +### 2. Manager-only provider in `Overflow.tsx` -The engine emits arrays of visible and invisible items. The wrapper then rebuilds React-friendly maps: +`Overflow.tsx` no longer subscribes to the full manager snapshot just to republish convenience state. -- `itemVisibility: Record` -- `groupVisibility: Record` -- `hasOverflow: boolean` +The provider now publishes: -This is a second state system layered over the engine's own state. +- `manager` +- registration helpers +- `updateOverflow` -That has two consequences: +and leaves snapshot reading to the selector-hook layer. -- extra JS work on every engine update -- extra React invalidation even when consumers only need a small slice +That means the provider is no longer a mirrored overflow-state distributor. ### 3. Context redistribution -The wrapper pushes state and registration functions through `OverflowContext`. +The wrapper pushes the manager plus registration functions through `OverflowContext`. -Using `react-context-selector` helps, but it does not eliminate all cost. The provider value still changes when: - -- `itemVisibility` is rebuilt -- `groupVisibility` changes -- `hasOverflow` changes - -So the bridge still turns engine updates into React subscription activity. +That keeps the provider value much more stable than the earlier mirrored-state version. Context churn is now tied mainly to manager identity and registration helper identity instead of every overflow snapshot change. ### 4. Two visibility channels There are really two visibility channels in the system: - DOM attributes written by the engine callback path -- React state exposed through hooks +- manager snapshot state exposed through selector hooks That duplication is useful for ergonomics, but it means the wrapper is not just a thin type-safe façade. It is a synchronizing layer. ## Concrete pain points -### Pain point 1: full map rebuild on every overflow update - -In `Overflow.tsx`, each `onUpdateOverflow` callback rebuilds a fresh `itemVisibility` object from the visible and invisible arrays. - -That means: +### Improvement: provider-level mirrored state is gone -- every update allocates a new map -- every update touches all items that changed set membership -- even consumers looking up a single item still depend on a rebuilt parent object +`Overflow.tsx` now uses an effect subscription for `onOverflowChange` and no longer republishes visibility state through context. -This cost is separate from the engine's own work. +That removes the weakest external-store subscription in the bridge and eliminates provider churn that was previously tied to every snapshot update. -### Pain point 2: React re-renders are coupled to engine churn +### Pain point 1: React re-renders are still coupled to engine churn The hook `useOverflowVisibility()` already warns that it re-renders for every overflow visibility change. @@ -120,7 +106,7 @@ That is a real signal that the bridge is not especially cheap for broad subscrip If an app reads the full visibility map, React work scales with every overflow event, even if the DOM attributes alone would have been enough for hiding. -### Pain point 3: menu state can cause follow-up engine work +### Pain point 2: menu state can still cause follow-up engine work `useOverflowMenu()` derives `isOverflowing` from `overflowCount`, and when that becomes true it calls `updateOverflow()` again in a layout effect. @@ -133,15 +119,13 @@ This is understandable because the menu itself changes occupied size, but archit So the bridge can create a second phase of work around menu activation. -### Pain point 4: option changes recreate the engine instance +### Improvement: option changes no longer recreate the engine instance -`useOverflowContainer()` recreates the overflow manager when observed options change after first mount. +`useOverflowContainer()` now creates one manager instance per container and reconfigures it through `setOptions()`. -That keeps behavior simple, but it means option changes are not incremental. They effectively reset the imperative engine layer. +That means option changes are now incremental instead of reconstructive. -This is acceptable for infrequent prop changes, but it is not a particularly elegant bridge if options are dynamic. - -### Pain point 5: trigger-style child cloning is restrictive +### Pain point 3: trigger-style child cloning is restrictive `Overflow` and `OverflowItem` use trigger-style helpers to clone children and merge refs. @@ -153,7 +137,13 @@ That introduces API constraints: This is not necessarily slow in isolation, but it is a sign that the bridge is paying API complexity to adapt imperative registration into React composition. -### Pain point 6: state and DOM can never be truly single-source +### Pain point 4: selector hooks still render from an external store + +The selector hook layer still uses `useSyncExternalStore` against the manager. + +That keeps the strongest external-store correctness semantics, but it also means the bridge has not fully moved to a mirrored React snapshot model. + +### Pain point 5: state and DOM can never be truly single-source The engine is the real source of truth for fit and visibility. @@ -171,11 +161,11 @@ sequenceDiagram participant Context as React context consumers React->>Hooks: render Overflow and items - Hooks->>Engine: register container and DOM nodes in layout effects + Hooks->>Engine: configure manager and register DOM nodes in layout effects Engine->>Engine: measure and compute overflow - Engine-->>Hooks: onUpdateOverflow callback - Hooks->>React: set overflowState - React->>Context: publish new provider value + Engine-->>Hooks: update snapshot and callbacks + Hooks->>React: selector subscriptions trigger re-render + React->>Context: publish manager and registration helpers Context-->>React: subscribed components re-render React->>Engine: some hooks may call updateOverflow again ``` @@ -190,7 +180,6 @@ The bridge adds cost in three other places. This includes: -- rebuilding visibility maps - creating new provider values - creating memoized derived objects for hooks such as `useOverflowVisibility()` @@ -202,7 +191,7 @@ This includes: - invalidating selector subscribers - re-running components that consume overflow state -- effect work after state changes, especially around menu registration/update +- effect work after snapshot changes, especially around menu registration/update This is the biggest bridge-specific cost bucket. @@ -233,22 +222,22 @@ So the bridge is functional and practical. The problem is not that it fails. The If you need to rank the React-side concerns by importance, this is the right order: -1. mirrored visibility state and full map rebuilds -2. React subscription and re-render churn -3. follow-up menu-triggered updates -4. engine recreation on option changes -5. child-cloning and ref-plumbing complexity +1. React subscription and re-render churn for broad consumers +2. follow-up menu-triggered updates +3. selector hooks depending on direct external-store rendering +4. child-cloning and ref-plumbing complexity +5. residual bridge synchronization cost ## Bottom line -The current bridge is workable, but not ideal. +The current bridge is workable and materially better than the earlier setter-plus-callback model, but not ideal. It is best understood as an adapter between two different worlds: - an imperative DOM measurement engine - a declarative React subscription model -That adapter necessarily adds synchronization, re-rendering, and some API awkwardness. The biggest structural weakness is that React mirrors and redistributes state the engine already owns instead of subscribing to a more canonical external store model. +That adapter still adds synchronization, re-rendering, and some API awkwardness. The biggest remaining structural question is whether the selector-hook path should continue rendering directly from the manager as an external store, or whether the bridge should eventually move back to a mirrored React snapshot model. If the question is "what should replace it?", that belongs in `overflow-northstar.md`. The purpose of this document is narrower: explain how the current bridge works, where it pays, and why it feels awkward. diff --git a/packages/react-components/react-overflow/library/etc/react-overflow.api.md b/packages/react-components/react-overflow/library/etc/react-overflow.api.md index 4c435fc9a02e79..d3cf0d3f61f9e2 100644 --- a/packages/react-components/react-overflow/library/etc/react-overflow.api.md +++ b/packages/react-components/react-overflow/library/etc/react-overflow.api.md @@ -10,6 +10,7 @@ import type { OnUpdateOverflow } from '@fluentui/priority-overflow'; import type { OverflowDividerEntry } from '@fluentui/priority-overflow'; import { OverflowGroupState } from '@fluentui/priority-overflow'; import type { OverflowItemEntry } from '@fluentui/priority-overflow'; +import type { OverflowManager } from '@fluentui/priority-overflow'; import * as React_2 from 'react'; // @public (undocumented) @@ -73,7 +74,8 @@ export const useOverflowContainer: (update: OnUpda // @internal (undocumented) export interface UseOverflowContainerReturn extends Pick { - containerRef: React_2.RefObject; + containerRef: React_2.RefCallback; + manager: OverflowManager | null; } // @internal (undocumented) diff --git a/packages/react-components/react-overflow/library/src/Overflow.cy.tsx b/packages/react-components/react-overflow/library/src/Overflow.cy.tsx index 47be40a1ea90bd..d490eff736138c 100644 --- a/packages/react-components/react-overflow/library/src/Overflow.cy.tsx +++ b/packages/react-components/react-overflow/library/src/Overflow.cy.tsx @@ -7,7 +7,7 @@ import { OverflowReorderObserver, useIsOverflowGroupVisible, useOverflowMenu, - useOverflowContext, + useOverflowVisibility, type OverflowProps, type OverflowItemProps, type OnOverflowChangeData, @@ -96,7 +96,7 @@ const Item = ({ children, width, ...overflowItemProps }: ItemProps) => { const Menu: React.FC<{ width?: number }> = ({ width }) => { const { isOverflowing, ref, overflowCount } = useOverflowMenu(); - const itemVisibility = useOverflowContext(ctx => ctx.itemVisibility); + const { itemVisibility } = useOverflowVisibility(); const selector = { [selectors.menu]: '', }; diff --git a/packages/react-components/react-overflow/library/src/components/Overflow.tsx b/packages/react-components/react-overflow/library/src/components/Overflow.tsx index fa82edb3635c97..aa82fa5e2a6365 100644 --- a/packages/react-components/react-overflow/library/src/components/Overflow.tsx +++ b/packages/react-components/react-overflow/library/src/components/Overflow.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { mergeClasses } from '@griffel/react'; -import type { OverflowGroupState, OverflowSnapshot, ObserveOptions } from '@fluentui/priority-overflow'; +import type { ObserveOptions, OverflowGroupState, OverflowSnapshot } from '@fluentui/priority-overflow'; import { applyTriggerPropsToChildren, getTriggerChild, @@ -10,7 +10,7 @@ import { useMergedRefs, } from '@fluentui/react-utilities'; -import { OverflowContext } from '../overflowContext'; +import { defaultOverflowManager, OverflowContext } from '../overflowContext'; import { updateVisibilityAttribute, useOverflowContainer } from '../useOverflowContainer'; import { useOverflowStyles } from './useOverflowStyles.styles'; @@ -20,19 +20,6 @@ interface OverflowState { groupVisibility: Record; } -const emptyOverflowState: OverflowState = { - hasOverflow: false, - itemVisibility: {}, - groupVisibility: {}, -}; - -const emptyOverflowSnapshot: OverflowSnapshot = { - hasOverflow: false, - overflowCount: 0, - itemVisibility: {}, - groupVisibility: {}, -}; - const toOverflowState = (snapshot: OverflowSnapshot): OverflowState => ({ hasOverflow: snapshot.hasOverflow, itemVisibility: snapshot.itemVisibility, @@ -80,32 +67,27 @@ export const Overflow = React.forwardRef((props: OverflowProps, ref) => { onUpdateItemVisibility: updateVisibilityAttribute, }); - const snapshot = React.useSyncExternalStore( - manager?.subscribe ?? (() => () => undefined), - () => (manager ? manager.getSnapshot() : emptyOverflowSnapshot), - () => (manager ? manager.getSnapshot() : emptyOverflowSnapshot), - ); - - const overflowState = React.useMemo( - () => (snapshot === emptyOverflowSnapshot ? emptyOverflowState : toOverflowState(snapshot)), - [snapshot], - ); - const lastReportedState = React.useRef(null); React.useEffect(() => { - if (!onOverflowChange) { + if (!manager || !onOverflowChange) { return; } - if (lastReportedState.current === null) { - lastReportedState.current = overflowState; - return; - } + lastReportedState.current = null; + + return manager.subscribe(() => { + const overflowState = toOverflowState(manager.getSnapshot()); - onOverflowChange(null, overflowState); - lastReportedState.current = overflowState; - }, [onOverflowChange, overflowState]); + if (lastReportedState.current === null) { + lastReportedState.current = overflowState; + return; + } + + onOverflowChange(null, overflowState); + lastReportedState.current = overflowState; + }); + }, [manager, onOverflowChange]); const child = getTriggerChild(children); const clonedChild = applyTriggerPropsToChildren(children, { @@ -116,10 +98,7 @@ export const Overflow = React.forwardRef((props: OverflowProps, ref) => { return ( undefined, observe: () => () => undefined, registerItem: () => () => undefined, @@ -36,9 +35,6 @@ const defaultManager: OverflowManager = { */ export interface OverflowContextValue { manager: OverflowManager; - itemVisibility: Record; - groupVisibility: Record; - hasOverflow: boolean; registerItem: (item: OverflowItemEntry) => () => void; registerOverflowMenu: (el: HTMLElement) => () => void; registerDivider: (divider: OverflowDividerEntry) => () => void; @@ -51,10 +47,7 @@ export const OverflowContext = createContext( ) as Context; const overflowContextDefaultValue: OverflowContextValue = { - manager: defaultManager, - itemVisibility: {}, - groupVisibility: {}, - hasOverflow: false, + manager: defaultOverflowManager, registerItem: () => () => null, updateOverflow: () => null, registerOverflowMenu: () => () => null, diff --git a/packages/react-components/react-overflow/library/src/useOverflowItem.test.tsx b/packages/react-components/react-overflow/library/src/useOverflowItem.test.tsx index 435ecdd1c4eaae..1114837d27d029 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowItem.test.tsx +++ b/packages/react-components/react-overflow/library/src/useOverflowItem.test.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import type { OverflowManager } from '@fluentui/priority-overflow'; import { useOverflowItem } from './useOverflowItem'; import type { OverflowContextValue } from './overflowContext'; import { OverflowContext } from './overflowContext'; @@ -6,9 +7,18 @@ import { renderHook } from '@testing-library/react-hooks'; const mockContextValue = (options: Partial = {}) => ({ - groupVisibility: {}, - hasOverflow: false, - itemVisibility: {}, + manager: { + attachOverflowMenu: jest.fn(() => () => undefined), + forceUpdate: jest.fn(), + getSnapshot: jest.fn(() => ({ hasOverflow: false, overflowCount: 0, itemVisibility: {}, groupVisibility: {} })), + observe: jest.fn(() => () => undefined), + registerDivider: jest.fn(() => () => undefined), + registerItem: jest.fn(() => () => undefined), + removeItem: jest.fn(), + setOptions: jest.fn(), + subscribe: jest.fn(() => () => undefined), + update: jest.fn(), + } as unknown as OverflowManager, registerItem: jest.fn(), updateOverflow: jest.fn(), ...options, diff --git a/packages/react-components/react-overflow/library/src/useOverflowVisibility.test.tsx b/packages/react-components/react-overflow/library/src/useOverflowVisibility.test.tsx index 08809222a3beaa..db08a94ad2a639 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowVisibility.test.tsx +++ b/packages/react-components/react-overflow/library/src/useOverflowVisibility.test.tsx @@ -34,8 +34,6 @@ describe('useOverflowVisibility', () => { value={ { manager, - groupVisibility, - itemVisibility, } as unknown as OverflowContextValue } /> From 7744e7598d5ce8fadad1716868d1ac1288a02d8f Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Mon, 25 May 2026 23:04:04 +0200 Subject: [PATCH 06/14] finalize overflow docs --- .../library/docs/overflow-algorithm.md | 34 +++++- .../library/docs/overflow-northstar.md | 103 ------------------ .../docs/react-overflow-react-bridge.md | 18 ++- 3 files changed, 47 insertions(+), 108 deletions(-) delete mode 100644 packages/react-components/react-overflow/library/docs/overflow-northstar.md diff --git a/packages/react-components/react-overflow/library/docs/overflow-algorithm.md b/packages/react-components/react-overflow/library/docs/overflow-algorithm.md index 6fab5ad9b525c4..d6895a532128bf 100644 --- a/packages/react-components/react-overflow/library/docs/overflow-algorithm.md +++ b/packages/react-components/react-overflow/library/docs/overflow-algorithm.md @@ -11,8 +11,6 @@ It is intentionally focused on the engine itself: queues, measurement, lifecycle For the React integration layer, see `docs/react-overflow-react-bridge.md`. -For open design directions, refactor ideas, and unresolved questions, see `docs/overflow-northstar.md`. - ## One-sentence model The engine keeps two priority queues of item ids, measures the observed container and registered elements, then repeatedly moves items between the visible and invisible queues until occupied size fits within available size while publishing a canonical snapshot and optional callbacks. @@ -139,6 +137,12 @@ The manager uses paired setup and cleanup boundaries for its runtime relationshi Important: the engine still exposes `removeItem()` as a lower-level removal helper, but the preferred lifecycle model is the cleanup returned from `registerItem()`. +Current recommendation: + +- keep `removeItem()` as a low-level internal escape hatch for now +- keep cleanup-returning registration as the primary lifecycle model +- only revisit tighter removal semantics if the team later chooses to narrow the internal manager surface further + ### 4. Update scheduling `update()` is microtask-debounced in production. Multiple resize and registration events in the same tick collapse into one `forceUpdate()` run. @@ -446,6 +450,32 @@ The manager still invokes: These remain useful for imperative side effects such as applying `data-overflowing` attributes, but they are no longer the only way to observe engine state. +Current recommendation: + +- keep `onUpdateOverflow` and `onUpdateItemVisibility` for now +- treat callbacks as secondary imperative hooks, not the primary readable state channel +- only revisit further callback pruning if a later cleanup pass can prove there is no remaining value in the current callback model + +## Deferred engine follow-up + +The following engine ideas are intentionally deferred rather than treated as active design work. + +### Handle-based registration + +Possible future shapes such as: + +```ts +registerItem(item): OverflowItemHandle; +registerDivider(divider): OverflowDividerHandle; +``` + +are intentionally deferred. + +Current recommendation: + +- do not add handle-based registration now +- only revisit it if metadata churn becomes a meaningful measured cost + ## Scenario atlas This section turns the abstract algorithm into concrete situations. The goal is to answer two questions for each case: diff --git a/packages/react-components/react-overflow/library/docs/overflow-northstar.md b/packages/react-components/react-overflow/library/docs/overflow-northstar.md deleted file mode 100644 index 639dfa8437aadd..00000000000000 --- a/packages/react-components/react-overflow/library/docs/overflow-northstar.md +++ /dev/null @@ -1,103 +0,0 @@ -# Overflow Northstar - -This document now tracks only the remaining delta after the lifecycle-boundary refactor. - -It is not the source of truth for current behavior. Current behavior is described by: - -- `overflow-algorithm.md` for the engine -- `react-overflow-react-bridge.md` for the React bridge - -The long-term goal is still to delete this document once the remaining open decisions have either been resolved or absorbed into the two specs. - -## What is complete - -The viable refactor that originally motivated this document is now implemented. - -Completed pieces: - -1. the engine contract is now centered on a long-lived `OverflowManager` -2. pure construction is separated from runtime connections -3. `setOptions()` is independent from observation -4. `observe(container)` returns cleanup instead of coupling configuration to observation -5. `attachOverflowMenu(element)` returns cleanup -6. `registerItem(item)` and `registerDivider(divider)` return cleanup -7. the engine publishes canonical readable state through `getSnapshot()` and `subscribe()` -8. `useOverflowContainer()` now keeps one manager instance per container and configures it incrementally -9. selector-based state-reading hooks now subscribe to manager-owned snapshot state -10. the component-level public API remains stable -11. `Overflow.tsx` and `OverflowContext` no longer republish mirrored visibility state through the provider - -Those conclusions are now part of current truth and should be read primarily from the engine and bridge specs. - -## Current state summary - -Today the system is best understood like this: - -1. the engine owns canonical overflow truth -2. the bridge owns React integration and ergonomics -3. the engine contract now has paired cleanup boundaries instead of a monolithic teardown model -4. the bridge has adopted manager subscription as its primary state-reading path -5. the provider layer is now manager-and-registration only, while selector hooks remain the external-store readers - -## Remaining open questions - -The remaining questions are narrower than the original refactor. - -### 1. Should the bridge keep the menu follow-up update path? - -`useOverflowMenu()` still calls `updateOverflow()` after the menu becomes layout-participating. - -This is understandable and correct today, but it remains a second-phase bridge feedback loop that may or may not deserve further simplification. - -### 2. Should lower-level removal helpers stay public on the engine surface? - -The current implementation still exposes `removeItem()`. - -That is compatible with the cleanup-boundary model, but it is not part of the minimal desired shape that originally motivated the northstar. The remaining question is whether it should remain as a low-level escape hatch or be folded away later. - -### 3. Do ideal registration handles still matter? - -The viable cleanup-returning API is in place. - -The remaining question is whether handle-based registration such as: - -```ts -registerItem(item): OverflowItemHandle; -registerDivider(divider): OverflowDividerHandle; -``` - -still buys enough to justify another engine step. - -Current recommendation: - -- not yet -- only revisit this if metadata churn becomes a meaningful remaining cost - -### 4. Do we need custom selector equality? - -Current recommendation remains: - -- not by default -- keep selections narrow first -- only add broader equality support if profiling proves it necessary - -## What no longer belongs here - -The following are no longer northstar-only conclusions because they are now implemented: - -- pure manager construction should not itself require cleanup -- runtime connections should have paired local cleanup -- the manager should expose canonical snapshot state and generic subscription -- the bridge should use one long-lived manager per container -- state-reading hooks should subscribe to manager-owned state - -Those points now belong in the engine and bridge specs, not here. - -## Exit condition - -This file should disappear once the remaining questions above are either: - -1. resolved in code and moved into the two specs, or -2. intentionally rejected as unnecessary follow-up work - -At that point, the engine spec and bridge spec should be able to stand on their own without a third design ledger. diff --git a/packages/react-components/react-overflow/library/docs/react-overflow-react-bridge.md b/packages/react-components/react-overflow/library/docs/react-overflow-react-bridge.md index 395db96773b1df..4b5079e618c8f2 100644 --- a/packages/react-components/react-overflow/library/docs/react-overflow-react-bridge.md +++ b/packages/react-components/react-overflow/library/docs/react-overflow-react-bridge.md @@ -119,6 +119,12 @@ This is understandable because the menu itself changes occupied size, but archit So the bridge can create a second phase of work around menu activation. +Current recommendation: + +- keep this follow-up update path for now +- treat it as an acceptable bridge feedback loop in the current menu participation model +- only revisit it if later profiling or a broader menu/measurement redesign offers a clearly better alternative + ### Improvement: option changes no longer recreate the engine instance `useOverflowContainer()` now creates one manager instance per container and reconfigures it through `setOptions()`. @@ -143,6 +149,12 @@ The selector hook layer still uses `useSyncExternalStore` against the manager. That keeps the strongest external-store correctness semantics, but it also means the bridge has not fully moved to a mirrored React snapshot model. +Current recommendation: + +- keep the selector-hook path on direct external-store reads for now +- do not add custom selector equality +- keep selector reads narrow and only revisit broader equality machinery if profiling proves it necessary + ### Pain point 5: state and DOM can never be truly single-source The engine is the real source of truth for fit and visibility. @@ -206,7 +218,7 @@ This includes: This is not the dominant runtime cost, but it makes the design more intricate and timing-sensitive. -For design directions, refactor ideas, and unresolved questions beyond the current bridge, see `overflow-northstar.md`. This document stays focused on the current bridge and where its cost comes from. +This document stays focused on the current bridge and where its cost comes from. ## What the bridge does well @@ -239,9 +251,9 @@ It is best understood as an adapter between two different worlds: That adapter still adds synchronization, re-rendering, and some API awkwardness. The biggest remaining structural question is whether the selector-hook path should continue rendering directly from the manager as an external store, or whether the bridge should eventually move back to a mirrored React snapshot model. -If the question is "what should replace it?", that belongs in `overflow-northstar.md`. The purpose of this document is narrower: explain how the current bridge works, where it pays, and why it feels awkward. +The purpose of this document is narrower: explain how the current bridge works, where it pays, and why it feels awkward. -That narrower scope is deliberate. Once the current bridge is described precisely, future improvement discussions can be grounded in specific costs and tradeoffs rather than broad impressions. +That narrower scope is deliberate. Now that the lifecycle refactor and its follow-up decisions are recorded here and in the engine spec, future improvement discussions can start directly from those two specs rather than from a third design ledger. ## Relevant source files From 0fc644d870d361149ab06a2d54df224028fb3192 Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Tue, 26 May 2026 09:22:38 +0200 Subject: [PATCH 07/14] simplify overflow container observation --- .../library/src/useOverflowContainer.ts | 107 +++++++++--------- 1 file changed, 51 insertions(+), 56 deletions(-) diff --git a/packages/react-components/react-overflow/library/src/useOverflowContainer.ts b/packages/react-components/react-overflow/library/src/useOverflowContainer.ts index 1bdf6e3da5e0af..4d3bf99b9dc9a0 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowContainer.ts +++ b/packages/react-components/react-overflow/library/src/useOverflowContainer.ts @@ -65,72 +65,67 @@ export const useOverflowContainer = ( ], ); - const [container, setContainer] = React.useState(null); + const overflowManagerRef = React.useRef(null); + const cleanupObservationRef = React.useRef<(() => void) | null>(null); + const observedContainerRef = React.useRef(null); + + if (!overflowManagerRef.current && canUseDOM()) { + overflowManagerRef.current = createOverflowManager(); + } const containerRef = React.useCallback>(node => { - setContainer(node); - }, []); + if (observedContainerRef.current === node) { + return; + } - const overflowManager = React.useState(() => - canUseDOM() ? createOverflowManager() : null, - )[0]; + cleanupObservationRef.current?.(); + cleanupObservationRef.current = null; + observedContainerRef.current = node; - useIsomorphicLayoutEffect(() => { - overflowManager?.setOptions(observeOptions); - }, [observeOptions, overflowManager]); + if (overflowManagerRef.current && node) { + cleanupObservationRef.current = overflowManagerRef.current.observe(node); + } + }, []); useIsomorphicLayoutEffect(() => { - if (!overflowManager || !container) { - return; - } + overflowManagerRef.current?.setOptions(observeOptions); + }, [observeOptions]); + + const registerItem = React.useCallback((item: OverflowItemEntry) => { + const deregisterItem = overflowManagerRef.current?.registerItem(item) ?? noop; + item.element.setAttribute(DATA_OVERFLOW_ITEM, ''); + + return () => { + item.element.removeAttribute(DATA_OVERFLOWING); + item.element.removeAttribute(DATA_OVERFLOW_ITEM); + deregisterItem(); + }; + }, []); - return overflowManager.observe(container); - }, [container, overflowManager]); - - const registerItem = React.useCallback( - (item: OverflowItemEntry) => { - const deregisterItem = overflowManager?.registerItem(item) ?? noop; - item.element.setAttribute(DATA_OVERFLOW_ITEM, ''); - - return () => { - item.element.removeAttribute(DATA_OVERFLOWING); - item.element.removeAttribute(DATA_OVERFLOW_ITEM); - deregisterItem(); - }; - }, - [overflowManager], - ); + const registerDivider = React.useCallback((divider: OverflowDividerEntry) => { + const el = divider.element; + const deregisterDivider = overflowManagerRef.current?.registerDivider(divider) ?? noop; + el.setAttribute(DATA_OVERFLOW_DIVIDER, ''); - const registerDivider = React.useCallback( - (divider: OverflowDividerEntry) => { - const el = divider.element; - const deregisterDivider = overflowManager?.registerDivider(divider) ?? noop; - el.setAttribute(DATA_OVERFLOW_DIVIDER, ''); - - return () => { - deregisterDivider(); - el.removeAttribute(DATA_OVERFLOW_DIVIDER); - }; - }, - [overflowManager], - ); + return () => { + deregisterDivider(); + el.removeAttribute(DATA_OVERFLOW_DIVIDER); + }; + }, []); - const registerOverflowMenu = React.useCallback( - (el: HTMLElement) => { - const detachOverflowMenu = overflowManager?.attachOverflowMenu(el) ?? noop; - el.setAttribute(DATA_OVERFLOW_MENU, ''); - - return () => { - detachOverflowMenu(); - el.removeAttribute(DATA_OVERFLOW_MENU); - }; - }, - [overflowManager], - ); + const registerOverflowMenu = React.useCallback((el: HTMLElement) => { + const detachOverflowMenu = overflowManagerRef.current?.attachOverflowMenu(el) ?? noop; + el.setAttribute(DATA_OVERFLOW_MENU, ''); + + return () => { + detachOverflowMenu(); + el.removeAttribute(DATA_OVERFLOW_MENU); + }; + }, []); const updateOverflow = React.useCallback(() => { - overflowManager?.update(); - }, [overflowManager]); + overflowManagerRef.current?.update(); + }, []); return { registerItem, @@ -138,7 +133,7 @@ export const useOverflowContainer = ( registerOverflowMenu, updateOverflow, containerRef, - manager: overflowManager, + manager: overflowManagerRef.current, }; }; From 94e7a92c41d3b5050acbc36c0925ccd6675b3d14 Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Tue, 26 May 2026 12:33:32 +0200 Subject: [PATCH 08/14] include paint probing to ensure a single paint correctness --- .../library/docs/overflow-algorithm.md | 35 +++ .../docs/react-overflow-react-bridge.md | 12 + .../library/src/Overflow.paint-probe.cy.tsx | 266 ++++++++++++++++++ 3 files changed, 313 insertions(+) create mode 100644 packages/react-components/react-overflow/library/src/Overflow.paint-probe.cy.tsx diff --git a/packages/react-components/react-overflow/library/docs/overflow-algorithm.md b/packages/react-components/react-overflow/library/docs/overflow-algorithm.md index d6895a532128bf..258d5901ea24a4 100644 --- a/packages/react-components/react-overflow/library/docs/overflow-algorithm.md +++ b/packages/react-components/react-overflow/library/docs/overflow-algorithm.md @@ -154,6 +154,33 @@ That means the lifecycle is usually: 3. a microtask is queued 4. the queued task performs one rebalance pass +## Initial mount convergence model + +For initial mount, the most practical mental model is: + +- a single-step path in non-overflowing or already-stable cases +- a multi-step path in the initial-overflow edge case, where the overflow menu becomes layout-participating only after the first overflow computation + +The reason is architectural rather than incidental: + +1. the container and items commit first +2. registration and observation schedule the first engine update +3. the first engine pass can compute overflow before the menu itself participates in layout +4. once overflow exists, the menu can mount and participate in size calculation +5. a second engine pass can then settle the final overflow split with menu width included + +Those steps describe the logical convergence path. The important architectural point is that initial overflowing mount is not conceptually a single-step path: menu participation can introduce a second correction step even without any later resize or user interaction. + +At the same time, those logical steps still occur inside the pre-paint mount pipeline for correctness purposes. In other words: + +- the engine may need more than one internal correction step +- but those steps are still expected to collapse into the same visible first paint on the normal initial-mount path + +So the right distinction is not "one step versus two steps". It is: + +- logically multi-step +- visually single-paint-final for correctness on the mount path this design is centered around + ### 5. Processing pass `forceUpdate()` calls `processOverflowItems()`. @@ -546,6 +573,14 @@ Functionally: - the hide loop repeatedly removes the worst visible candidate - once the first item hides, the overflow menu may enter the occupied-size equation, which can force one more hide than a naive width sum would suggest +Mount-time convergence note: + +- this is still the main case where the system needs a second logical correction step +- the first pass can compute a provisional overflow state before menu participation +- a follow-up pass can then settle the final state once the menu mounts and contributes its width + +For documentation purposes, that should be read as an internal correction sequence, not as an instruction that a second visible paint is expected. The implementation is allowed to take more than one logical step while still reaching the final correct DOM before the browser presents the first visible frame. + Cost profile: - JS: proportional to how many items need to be hidden diff --git a/packages/react-components/react-overflow/library/docs/react-overflow-react-bridge.md b/packages/react-components/react-overflow/library/docs/react-overflow-react-bridge.md index 4b5079e618c8f2..c650391e4de113 100644 --- a/packages/react-components/react-overflow/library/docs/react-overflow-react-bridge.md +++ b/packages/react-components/react-overflow/library/docs/react-overflow-react-bridge.md @@ -125,6 +125,18 @@ Current recommendation: - treat it as an acceptable bridge feedback loop in the current menu participation model - only revisit it if later profiling or a broader menu/measurement redesign offers a clearly better alternative +Important nuance: + +- the follow-up update remains a real logical step +- the timing of that step relative to paint depends on how the surrounding registration and subscription work is scheduled + +For the current mount path, the important distinction is the same as in the engine spec: + +- the bridge can still perform more than one logical correction step while mounting +- but those steps are expected to collapse into a single visible first paint for correctness purposes on the initial mount path this bridge is centered around + +So the existence of a follow-up update does not automatically imply a second visible paint. It means the bridge has a multi-step mount-time convergence model that still aims to finish before the first presented frame. + ### Improvement: option changes no longer recreate the engine instance `useOverflowContainer()` now creates one manager instance per container and reconfigures it through `setOptions()`. diff --git a/packages/react-components/react-overflow/library/src/Overflow.paint-probe.cy.tsx b/packages/react-components/react-overflow/library/src/Overflow.paint-probe.cy.tsx new file mode 100644 index 00000000000000..ef8ce8a1254e74 --- /dev/null +++ b/packages/react-components/react-overflow/library/src/Overflow.paint-probe.cy.tsx @@ -0,0 +1,266 @@ +import * as React from 'react'; +import { mount as mountBase } from '@fluentui/scripts-cypress'; +import { + Overflow, + OverflowItem, + useOverflowMenu, + type OverflowProps, + type OverflowItemProps, +} from '@fluentui/react-overflow'; +import type { DistributiveOmit } from '@fluentui/react-utilities'; + +// Disable StrictMode so the probe measures a single mount/commit path. +const mount = (element: React.ReactElement) => mountBase(element, { strict: false }); + +const selectors = { + container: 'data-test-container', + item: 'data-test-item', + menu: 'data-test-menu', + probe: 'data-test-paint-probe', + probePhase: 'data-test-paint-phase', +}; + +type PaintPhaseSnapshot = { + menuText: string | null; + overflowingItemIds: string[]; +}; + +const readPaintPhaseSnapshot = (): PaintPhaseSnapshot => { + const menu = document.querySelector(`[${selectors.menu}]`); + const overflowingItemIds = Array.from(document.querySelectorAll(`[${selectors.item}]`)) + .filter(item => item.getAttribute('data-overflowing') !== null) + .map(item => item.getAttribute(selectors.item) ?? ''); + + return { + menuText: menu?.textContent ?? null, + overflowingItemIds, + }; +}; + +const writePhaseSnapshot = (name: string, phase: 'layout' | 'effect' | 'raf1', snapshot: PaintPhaseSnapshot) => { + const probeRoot = document.querySelector(`[${selectors.probe}="${name}"]`); + const phaseNode = probeRoot?.querySelector(`[${selectors.probePhase}="${phase}"]`); + + if (phaseNode) { + phaseNode.textContent = JSON.stringify(snapshot); + } +}; + +const Container: React.FC<{ children?: React.ReactNode; size?: number } & Omit> = ({ + children, + size, + ...userProps +}) => { + const selector = { + [selectors.container]: '', + }; + + return ( + +
+ {children} +
+
+ ); +}; + +type ItemProps = { children?: React.ReactNode; width?: number | string } & DistributiveOmit< + OverflowItemProps, + 'children' +>; + +const Item = ({ children, width, ...overflowItemProps }: ItemProps) => { + const selector = { + [selectors.item]: overflowItemProps.id, + }; + + return ( + + + + ); +}; + +const Menu = () => { + const { isOverflowing, ref, overflowCount } = useOverflowMenu(); + const selector = { + [selectors.menu]: '', + }; + + if (!isOverflowing) { + return null; + } + + return ( + + ); +}; + +const PaintPhaseProbe: React.FC<{ name: string }> = ({ name }) => { + React.useLayoutEffect(() => { + writePhaseSnapshot(name, 'layout', readPaintPhaseSnapshot()); + }, [name]); + + React.useEffect(() => { + writePhaseSnapshot(name, 'effect', readPaintPhaseSnapshot()); + + requestAnimationFrame(() => { + writePhaseSnapshot(name, 'raf1', readPaintPhaseSnapshot()); + }); + }, [name]); + + return null; +}; + +const PaintPhaseProbeHarness: React.FC<{ name: string; children: React.ReactNode }> = ({ name, children }) => { + return ( + <> + {children} +
+
+        
+        
+      
+ + + ); +}; + +const assertProbeConvergence = (name: string, expected: PaintPhaseSnapshot) => { + cy.get(`[${selectors.probe}="${name}"] [${selectors.probePhase}="raf1"]`).should($node => { + expect($node.text(), 'raf1 snapshot marker').not.to.equal(''); + }); + + cy.get(`[${selectors.probe}="${name}"]`).then($probe => { + const read = (phase: 'layout' | 'effect' | 'raf1') => { + const text = $probe.find(`[${selectors.probePhase}="${phase}"]`).text(); + return JSON.parse(text) as PaintPhaseSnapshot; + }; + + const layout = read('layout'); + const effect = read('effect'); + const raf1 = read('raf1'); + const debugSnapshots = `layout=${JSON.stringify(layout)} effect=${JSON.stringify(effect)} raf1=${JSON.stringify( + raf1, + )}`; + + expect(layout, `missing layout snapshot; ${debugSnapshots}`).to.exist; + expect(effect, `missing effect snapshot; ${debugSnapshots}`).to.exist; + expect(raf1, `missing first-raf snapshot; ${debugSnapshots}`).to.exist; + expect(raf1, `unexpected first-raf snapshot; ${debugSnapshots}`).to.deep.equal(expected); + }); +}; + +describe('Overflow paint probe', () => { + beforeEach(() => { + cy.viewport(700, 700); + }); + + it('is already final by first rAF on initial overflowing mount', { retries: 0 }, () => { + const mapHelper = new Array(10).fill(0).map((_, i) => i); + + mount( + + + {mapHelper.map(i => ( + + {i} + + ))} + + + , + ); + + assertProbeConvergence('initial-overflow', { + menuText: '+5', + overflowingItemIds: ['5', '6', '7', '8', '9'], + }); + }); + + it('is already final by first rAF for a slightly wider initial-overflow case', { retries: 0 }, () => { + const mapHelper = new Array(10).fill(0).map((_, i) => i); + + mount( + + + {mapHelper.map(i => ( + + {i} + + ))} + + + , + ); + + assertProbeConvergence('initial-overflow-wide', { + menuText: '+4', + overflowingItemIds: ['6', '7', '8', '9'], + }); + }); + + it('is already final by first rAF for an uneven-width initial-overflow case', { retries: 0 }, () => { + mount( + + + + Item 0 + + + Item 1 + + + Super Long Item 2 + + + 3 + + Item 4 + Item 5 + + + , + ); + + assertProbeConvergence('initial-overflow-uneven', { + menuText: '+2', + overflowingItemIds: ['4', '5'], + }); + }); + + it('is already final by first rAF when the menu never becomes visible', { retries: 0 }, () => { + const mapHelper = new Array(5).fill(0).map((_, i) => i); + + mount( + + + {mapHelper.map(i => ( + + {i} + + ))} + + + , + ); + + assertProbeConvergence('initial-no-menu', { + menuText: null, + overflowingItemIds: [], + }); + }); +}); From 36b5ff24c44eb3ba6823303eadcaad1ece735b45 Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Tue, 26 May 2026 19:48:13 +0200 Subject: [PATCH 09/14] chore: remove manager state from Overflow container --- .../etc/priority-overflow.api.md | 2 +- .../priority-overflow/src/overflowManager.ts | 14 +--- .../library/etc/react-overflow.api.md | 9 ++- .../library/src/components/Overflow.tsx | 78 ++++++++----------- .../library/src/overflowContext.ts | 73 +++++++++-------- .../library/src/useIsOverflowGroupVisible.ts | 4 +- .../library/src/useIsOverflowItemVisible.ts | 4 +- .../library/src/useOverflowContainer.ts | 10 ++- .../library/src/useOverflowCount.ts | 4 +- .../library/src/useOverflowMenu.ts | 9 +-- .../library/src/useOverflowSelector.ts | 15 ---- .../library/src/useOverflowSnapshot.ts | 23 ++++++ .../library/src/useOverflowVisibility.ts | 9 +-- 13 files changed, 126 insertions(+), 128 deletions(-) delete mode 100644 packages/react-components/react-overflow/library/src/useOverflowSelector.ts create mode 100644 packages/react-components/react-overflow/library/src/useOverflowSnapshot.ts diff --git a/packages/react-components/priority-overflow/etc/priority-overflow.api.md b/packages/react-components/priority-overflow/etc/priority-overflow.api.md index 7af513e50383dd..36cb1bdc65c0e7 100644 --- a/packages/react-components/priority-overflow/etc/priority-overflow.api.md +++ b/packages/react-components/priority-overflow/etc/priority-overflow.api.md @@ -5,7 +5,7 @@ ```ts // @internal (undocumented) -export function createOverflowManager(): OverflowManager; +export function createOverflowManager(initialOptions: Required): OverflowManager; // @public (undocumented) export interface ObserveOptions { diff --git a/packages/react-components/priority-overflow/src/overflowManager.ts b/packages/react-components/priority-overflow/src/overflowManager.ts index ee4f038d32fcf7..b4975535d36e79 100644 --- a/packages/react-components/priority-overflow/src/overflowManager.ts +++ b/packages/react-components/priority-overflow/src/overflowManager.ts @@ -16,7 +16,7 @@ import type { * @internal * @returns overflow manager instance */ -export function createOverflowManager(): OverflowManager { +export function createOverflowManager(initialOptions: Required): OverflowManager { // calls to `offsetWidth or offsetHeight` can happen multiple times in an update // Use a cache to avoid causing too many recalcs and avoid scripting time to meausure sizes const sizeCache = new Map(); @@ -27,16 +27,7 @@ export function createOverflowManager(): OverflowManager { // If true, next update will dispatch to onUpdateOverflow even if queue top states don't change // Initially true to force dispatch on first mount let forceDispatch = true; - const options: Required = { - padding: 10, - overflowAxis: 'horizontal', - overflowDirection: 'end', - minimumVisible: 0, - onUpdateItemVisibility: () => undefined, - onUpdateOverflow: () => undefined, - hasHiddenItems: false, - }; - + const options: Required = initialOptions; const overflowItems: Record = {}; const overflowDividers: Record = {}; const listeners = new Set<() => void>(); @@ -255,6 +246,7 @@ export function createOverflowManager(): OverflowManager { }; const setOptions: OverflowManager['setOptions'] = nextOptions => { + if (options === nextOptions) return; const previousAxis = options.overflowAxis; const previousDirection = options.overflowDirection; const previousPadding = options.padding; diff --git a/packages/react-components/react-overflow/library/etc/react-overflow.api.md b/packages/react-components/react-overflow/library/etc/react-overflow.api.md index d3cf0d3f61f9e2..fee1debc222623 100644 --- a/packages/react-components/react-overflow/library/etc/react-overflow.api.md +++ b/packages/react-components/react-overflow/library/etc/react-overflow.api.md @@ -4,13 +4,13 @@ ```ts -import type { ContextSelector } from '@fluentui/react-context-selector'; import type { ObserveOptions } from '@fluentui/priority-overflow'; import type { OnUpdateOverflow } from '@fluentui/priority-overflow'; import type { OverflowDividerEntry } from '@fluentui/priority-overflow'; -import { OverflowGroupState } from '@fluentui/priority-overflow'; +import type { OverflowGroupState } from '@fluentui/priority-overflow'; import type { OverflowItemEntry } from '@fluentui/priority-overflow'; import type { OverflowManager } from '@fluentui/priority-overflow'; +import type { OverflowSnapshot } from '@fluentui/priority-overflow'; import * as React_2 from 'react'; // @public (undocumented) @@ -79,7 +79,10 @@ export interface UseOverflowContainerReturn extend } // @internal (undocumented) -export const useOverflowContext: (selector: ContextSelector) => SelectedValue; +export const useOverflowContext: { + (selector: ContextSelector): SelectedValue; + (): OverflowContextValue; +}; // @public (undocumented) export const useOverflowCount: () => number; diff --git a/packages/react-components/react-overflow/library/src/components/Overflow.tsx b/packages/react-components/react-overflow/library/src/components/Overflow.tsx index aa82fa5e2a6365..ec6055a31962c4 100644 --- a/packages/react-components/react-overflow/library/src/components/Overflow.tsx +++ b/packages/react-components/react-overflow/library/src/components/Overflow.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { mergeClasses } from '@griffel/react'; -import type { ObserveOptions, OverflowGroupState, OverflowSnapshot } from '@fluentui/priority-overflow'; +import type { ObserveOptions, OnUpdateOverflow, OverflowGroupState } from '@fluentui/priority-overflow'; import { applyTriggerPropsToChildren, getTriggerChild, @@ -10,7 +10,7 @@ import { useMergedRefs, } from '@fluentui/react-utilities'; -import { defaultOverflowManager, OverflowContext } from '../overflowContext'; +import { OverflowContext, type OverflowContextValue } from '../overflowContext'; import { updateVisibilityAttribute, useOverflowContainer } from '../useOverflowContainer'; import { useOverflowStyles } from './useOverflowStyles.styles'; @@ -20,12 +20,6 @@ interface OverflowState { groupVisibility: Record; } -const toOverflowState = (snapshot: OverflowSnapshot): OverflowState => ({ - hasOverflow: snapshot.hasOverflow, - itemVisibility: snapshot.itemVisibility, - groupVisibility: snapshot.groupVisibility, -}); - export interface OnOverflowChangeData extends OverflowState {} /** @@ -57,8 +51,24 @@ export const Overflow = React.forwardRef((props: OverflowProps, ref) => { hasHiddenItems, } = props; + const update: OnUpdateOverflow = data => { + const { visibleItems, invisibleItems, groupVisibility } = data; + + const itemVisibility: Record = {}; + visibleItems.forEach(item => { + itemVisibility[item.id] = true; + }); + invisibleItems.forEach(x => (itemVisibility[x.id] = false)); + const newState = { + hasOverflow: data.invisibleItems.length > 0, + itemVisibility, + groupVisibility, + }; + onOverflowChange?.(null, { ...newState }); + }; + const { containerRef, manager, registerItem, updateOverflow, registerOverflowMenu, registerDivider } = - useOverflowContainer(() => undefined, { + useOverflowContainer(update, { overflowDirection, overflowAxis, padding, @@ -67,46 +77,26 @@ export const Overflow = React.forwardRef((props: OverflowProps, ref) => { onUpdateItemVisibility: updateVisibilityAttribute, }); - const lastReportedState = React.useRef(null); - - React.useEffect(() => { - if (!manager || !onOverflowChange) { - return; - } - - lastReportedState.current = null; - - return manager.subscribe(() => { - const overflowState = toOverflowState(manager.getSnapshot()); - - if (lastReportedState.current === null) { - lastReportedState.current = overflowState; - return; - } - - onOverflowChange(null, overflowState); - lastReportedState.current = overflowState; - }); - }, [manager, onOverflowChange]); - const child = getTriggerChild(children); const clonedChild = applyTriggerPropsToChildren(children, { ref: useMergedRefs(containerRef, ref, getReactElementRef(child)), className: mergeClasses('fui-Overflow', styles.overflowMenu, styles.overflowingItems, child?.props.className), }); - return ( - - {clonedChild} - + const ctx: OverflowContextValue = React.useMemo( + () => ({ + groupVisibility: {}, + itemVisibility: {}, + hasOverflow: false, + registerItem, + updateOverflow, + registerOverflowMenu, + registerDivider, + getSnapshot: manager!.getSnapshot, + subscribe: manager!.subscribe, + }), + [manager, registerItem, updateOverflow, registerOverflowMenu, registerDivider], ); + + return {clonedChild}; }); diff --git a/packages/react-components/react-overflow/library/src/overflowContext.ts b/packages/react-components/react-overflow/library/src/overflowContext.ts index 3e473a9cffdb7a..de306113169c23 100644 --- a/packages/react-components/react-overflow/library/src/overflowContext.ts +++ b/packages/react-components/react-overflow/library/src/overflowContext.ts @@ -1,62 +1,73 @@ 'use client'; -import type * as React from 'react'; import type { OverflowItemEntry, OverflowDividerEntry, - OverflowManager, + OverflowGroupState, OverflowSnapshot, } from '@fluentui/priority-overflow'; -import type { ContextSelector, Context } from '@fluentui/react-context-selector'; -import { createContext, useContextSelector } from '@fluentui/react-context-selector'; - -const defaultSnapshot: OverflowSnapshot = { - hasOverflow: false, - overflowCount: 0, - itemVisibility: {}, - groupVisibility: {}, -}; - -export const defaultOverflowManager: OverflowManager = { - setOptions: () => undefined, - observe: () => () => undefined, - registerItem: () => () => undefined, - removeItem: () => undefined, - update: () => undefined, - forceUpdate: () => undefined, - attachOverflowMenu: () => () => undefined, - registerDivider: () => () => undefined, - getSnapshot: () => defaultSnapshot, - subscribe: () => () => undefined, -}; +import * as React from 'react'; /** * @internal */ export interface OverflowContextValue { - manager: OverflowManager; + /** + * @deprecated This value is not guaranteed to be up to date and should not be used directly. Use getSnapshot or the provided hooks instead + */ + itemVisibility: Record; + /** + * @deprecated This value is not guaranteed to be up to date and should not be used directly. Use getSnapshot or the provided hooks instead + */ + groupVisibility: Record; + /** + * @deprecated This value is not guaranteed to be up to date and should not be used directly. Use getSnapshot or the provided hooks instead + */ + hasOverflow: boolean; registerItem: (item: OverflowItemEntry) => () => void; registerOverflowMenu: (el: HTMLElement) => () => void; registerDivider: (divider: OverflowDividerEntry) => () => void; updateOverflow: (padding?: number) => void; containerRef?: React.RefObject; + getSnapshot: () => OverflowSnapshot; + subscribe: (listener: () => void) => () => void; } -export const OverflowContext = createContext( +export const OverflowContext = React.createContext( undefined, -) as Context; +) as React.Context; const overflowContextDefaultValue: OverflowContextValue = { - manager: defaultOverflowManager, + hasOverflow: false, + itemVisibility: {}, + groupVisibility: {}, registerItem: () => () => null, updateOverflow: () => null, registerOverflowMenu: () => () => null, registerDivider: () => () => null, + getSnapshot: () => ({ + hasOverflow: false, + overflowCount: 0, + itemVisibility: {}, + groupVisibility: {}, + }), + subscribe: () => () => null, }; +type ContextSelector = (context: TContext) => TSelected; + /** * @internal */ -export const useOverflowContext = ( - selector: ContextSelector, -): SelectedValue => useContextSelector(OverflowContext, (ctx = overflowContextDefaultValue) => selector(ctx)); +export const useOverflowContext: { + (selector: ContextSelector): SelectedValue; + (): OverflowContextValue; +} = + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (selector?: ContextSelector): any => { + const context = React.useContext(OverflowContext) ?? overflowContextDefaultValue; + if (selector) { + return selector(context); + } + return context; + }; diff --git a/packages/react-components/react-overflow/library/src/useIsOverflowGroupVisible.ts b/packages/react-components/react-overflow/library/src/useIsOverflowGroupVisible.ts index a449bcb1c873b7..145bef2063fed2 100644 --- a/packages/react-components/react-overflow/library/src/useIsOverflowGroupVisible.ts +++ b/packages/react-components/react-overflow/library/src/useIsOverflowGroupVisible.ts @@ -1,12 +1,12 @@ 'use client'; import type { OverflowGroupState } from '@fluentui/priority-overflow'; -import { useOverflowSelector } from './useOverflowSelector'; +import { useOverflowSnapshot } from './useOverflowSnapshot'; /** * @param id - unique identifier for a group of overflow items * @returns visibility state of the group */ export function useIsOverflowGroupVisible(id: string): OverflowGroupState { - return useOverflowSelector(snapshot => snapshot.groupVisibility[id]); + return useOverflowSnapshot().groupVisibility[id]; } diff --git a/packages/react-components/react-overflow/library/src/useIsOverflowItemVisible.ts b/packages/react-components/react-overflow/library/src/useIsOverflowItemVisible.ts index c387ad993fa73a..c0ea6d6e662ae5 100644 --- a/packages/react-components/react-overflow/library/src/useIsOverflowItemVisible.ts +++ b/packages/react-components/react-overflow/library/src/useIsOverflowItemVisible.ts @@ -1,11 +1,11 @@ 'use client'; -import { useOverflowSelector } from './useOverflowSelector'; +import { useOverflowSnapshot } from './useOverflowSnapshot'; /** * @param id - unique identifier for the item used by the overflow manager * @returns visibility state of an overflow item */ export function useIsOverflowItemVisible(id: string): boolean { - return !!useOverflowSelector(snapshot => snapshot.itemVisibility[id]); + return !!useOverflowSnapshot().itemVisibility[id]; } diff --git a/packages/react-components/react-overflow/library/src/useOverflowContainer.ts b/packages/react-components/react-overflow/library/src/useOverflowContainer.ts index 4d3bf99b9dc9a0..67168274703421 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowContainer.ts +++ b/packages/react-components/react-overflow/library/src/useOverflowContainer.ts @@ -14,7 +14,7 @@ import type { OverflowManager, ObserveOptions, } from '@fluentui/priority-overflow'; -import { canUseDOM, useEventCallback, useIsomorphicLayoutEffect } from '@fluentui/react-utilities'; +import { canUseDOM, useEventCallback } from '@fluentui/react-utilities'; import type { UseOverflowContainerReturn } from './types'; import { DATA_OVERFLOWING, DATA_OVERFLOW_DIVIDER, DATA_OVERFLOW_ITEM, DATA_OVERFLOW_MENU } from './constants'; @@ -44,7 +44,7 @@ export const useOverflowContainer = ( const onUpdateOverflow = useEventCallback(update); const onUpdateItemVisibilityCallback = useEventCallback(onUpdateItemVisibility); - const observeOptions = React.useMemo( + const observeOptions: Required = React.useMemo( () => ({ overflowAxis, overflowDirection, @@ -69,8 +69,9 @@ export const useOverflowContainer = ( const cleanupObservationRef = React.useRef<(() => void) | null>(null); const observedContainerRef = React.useRef(null); + // eslint-disable-next-line react-hooks/refs if (!overflowManagerRef.current && canUseDOM()) { - overflowManagerRef.current = createOverflowManager(); + overflowManagerRef.current = createOverflowManager(observeOptions); } const containerRef = React.useCallback>(node => { @@ -87,7 +88,7 @@ export const useOverflowContainer = ( } }, []); - useIsomorphicLayoutEffect(() => { + React.useEffect(() => { overflowManagerRef.current?.setOptions(observeOptions); }, [observeOptions]); @@ -133,6 +134,7 @@ export const useOverflowContainer = ( registerOverflowMenu, updateOverflow, containerRef, + // eslint-disable-next-line react-hooks/refs manager: overflowManagerRef.current, }; }; diff --git a/packages/react-components/react-overflow/library/src/useOverflowCount.ts b/packages/react-components/react-overflow/library/src/useOverflowCount.ts index fe9117b109f0fd..e79068d4b8b418 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowCount.ts +++ b/packages/react-components/react-overflow/library/src/useOverflowCount.ts @@ -1,8 +1,8 @@ 'use client'; -import { useOverflowSelector } from './useOverflowSelector'; +import { useOverflowSnapshot } from './useOverflowSnapshot'; /** * @returns Number of items that are overflowing */ -export const useOverflowCount = (): number => useOverflowSelector(snapshot => snapshot.overflowCount); +export const useOverflowCount = (): number => useOverflowSnapshot().overflowCount; diff --git a/packages/react-components/react-overflow/library/src/useOverflowMenu.ts b/packages/react-components/react-overflow/library/src/useOverflowMenu.ts index e9451c78d9178b..f4cc295f7c0053 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowMenu.ts +++ b/packages/react-components/react-overflow/library/src/useOverflowMenu.ts @@ -11,8 +11,7 @@ export function useOverflowMenu( ): { ref: React.MutableRefObject; overflowCount: number; isOverflowing: boolean } { const elementId = useId('overflow-menu', id); const overflowCount = useOverflowCount(); - const registerOverflowMenu = useOverflowContext(v => v.registerOverflowMenu); - const updateOverflow = useOverflowContext(v => v.updateOverflow); + const { registerOverflowMenu } = useOverflowContext(); const ref = React.useRef(null); const isOverflowing = overflowCount > 0; @@ -22,11 +21,5 @@ export function useOverflowMenu( } }, [registerOverflowMenu, isOverflowing, elementId]); - useIsomorphicLayoutEffect(() => { - if (isOverflowing) { - updateOverflow(); - } - }, [isOverflowing, updateOverflow, ref]); - return { ref, overflowCount, isOverflowing }; } diff --git a/packages/react-components/react-overflow/library/src/useOverflowSelector.ts b/packages/react-components/react-overflow/library/src/useOverflowSelector.ts deleted file mode 100644 index b7b4622f9c9973..00000000000000 --- a/packages/react-components/react-overflow/library/src/useOverflowSelector.ts +++ /dev/null @@ -1,15 +0,0 @@ -'use client'; - -import * as React from 'react'; -import type { OverflowSnapshot } from '@fluentui/priority-overflow'; -import { useOverflowContext } from './overflowContext'; - -export function useOverflowSelector(selector: (snapshot: OverflowSnapshot) => T): T { - const manager = useOverflowContext(ctx => ctx.manager); - - return React.useSyncExternalStore( - manager.subscribe, - () => selector(manager.getSnapshot()), - () => selector(manager.getSnapshot()), - ); -} diff --git a/packages/react-components/react-overflow/library/src/useOverflowSnapshot.ts b/packages/react-components/react-overflow/library/src/useOverflowSnapshot.ts new file mode 100644 index 00000000000000..7e707dacbe4cee --- /dev/null +++ b/packages/react-components/react-overflow/library/src/useOverflowSnapshot.ts @@ -0,0 +1,23 @@ +'use client'; + +import type { OverflowSnapshot } from '@fluentui/priority-overflow'; +// import { useIsomorphicLayoutEffect } from '@fluentui/react-utilities'; +// import { flushSync } from 'react-dom'; +import { useOverflowContext } from './overflowContext'; +import * as React from 'react'; + +export const useOverflowSnapshot = (): OverflowSnapshot => { + const { getSnapshot, subscribe } = useOverflowContext(); + // const [value, setValue] = React.useState(() => getSnapshot()); + // useIsomorphicLayoutEffect( + // () => + // subscribe(() => { + // flushSync(() => { + // setValue(getSnapshot()); + // }); + // }), + // [getSnapshot, subscribe], + // ); + // return value; + return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +}; diff --git a/packages/react-components/react-overflow/library/src/useOverflowVisibility.ts b/packages/react-components/react-overflow/library/src/useOverflowVisibility.ts index 5688fed8733709..de98d7c05cd3e9 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowVisibility.ts +++ b/packages/react-components/react-overflow/library/src/useOverflowVisibility.ts @@ -1,7 +1,8 @@ 'use client'; import * as React from 'react'; -import { useOverflowSelector } from './useOverflowSelector'; +import { useOverflowSnapshot } from './useOverflowSnapshot'; +import type { OverflowGroupState } from '@fluentui/priority-overflow'; /** * A hook that returns the visibility status of all items and groups. @@ -14,10 +15,8 @@ import { useOverflowSelector } from './useOverflowSelector'; */ export function useOverflowVisibility(): { itemVisibility: Record; - groupVisibility: Record; + groupVisibility: Record; } { - const itemVisibility = useOverflowSelector(snapshot => snapshot.itemVisibility); - const groupVisibility = useOverflowSelector(snapshot => snapshot.groupVisibility); - + const { itemVisibility, groupVisibility } = useOverflowSnapshot(); return React.useMemo(() => ({ itemVisibility, groupVisibility }), [itemVisibility, groupVisibility]); } From 827911ab5206dc755ce6d70df6935fa451694f10 Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Tue, 26 May 2026 21:31:09 +0200 Subject: [PATCH 10/14] rename to useSyncOverflowSnapshot --- .../library/src/useIsOverflowGroupVisible.ts | 4 ++-- .../library/src/useIsOverflowItemVisible.ts | 4 ++-- .../library/src/useOverflowCount.ts | 4 ++-- .../library/src/useOverflowSnapshot.ts | 17 ++--------------- .../library/src/useOverflowVisibility.ts | 4 ++-- 5 files changed, 10 insertions(+), 23 deletions(-) diff --git a/packages/react-components/react-overflow/library/src/useIsOverflowGroupVisible.ts b/packages/react-components/react-overflow/library/src/useIsOverflowGroupVisible.ts index 145bef2063fed2..50dab505f49bf0 100644 --- a/packages/react-components/react-overflow/library/src/useIsOverflowGroupVisible.ts +++ b/packages/react-components/react-overflow/library/src/useIsOverflowGroupVisible.ts @@ -1,12 +1,12 @@ 'use client'; import type { OverflowGroupState } from '@fluentui/priority-overflow'; -import { useOverflowSnapshot } from './useOverflowSnapshot'; +import { useSyncOverflowSnapshot } from './useOverflowSnapshot'; /** * @param id - unique identifier for a group of overflow items * @returns visibility state of the group */ export function useIsOverflowGroupVisible(id: string): OverflowGroupState { - return useOverflowSnapshot().groupVisibility[id]; + return useSyncOverflowSnapshot().groupVisibility[id]; } diff --git a/packages/react-components/react-overflow/library/src/useIsOverflowItemVisible.ts b/packages/react-components/react-overflow/library/src/useIsOverflowItemVisible.ts index c0ea6d6e662ae5..9833fc567893e4 100644 --- a/packages/react-components/react-overflow/library/src/useIsOverflowItemVisible.ts +++ b/packages/react-components/react-overflow/library/src/useIsOverflowItemVisible.ts @@ -1,11 +1,11 @@ 'use client'; -import { useOverflowSnapshot } from './useOverflowSnapshot'; +import { useSyncOverflowSnapshot } from './useOverflowSnapshot'; /** * @param id - unique identifier for the item used by the overflow manager * @returns visibility state of an overflow item */ export function useIsOverflowItemVisible(id: string): boolean { - return !!useOverflowSnapshot().itemVisibility[id]; + return !!useSyncOverflowSnapshot().itemVisibility[id]; } diff --git a/packages/react-components/react-overflow/library/src/useOverflowCount.ts b/packages/react-components/react-overflow/library/src/useOverflowCount.ts index e79068d4b8b418..066bc81e721558 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowCount.ts +++ b/packages/react-components/react-overflow/library/src/useOverflowCount.ts @@ -1,8 +1,8 @@ 'use client'; -import { useOverflowSnapshot } from './useOverflowSnapshot'; +import { useSyncOverflowSnapshot } from './useOverflowSnapshot'; /** * @returns Number of items that are overflowing */ -export const useOverflowCount = (): number => useOverflowSnapshot().overflowCount; +export const useOverflowCount = (): number => useSyncOverflowSnapshot().overflowCount; diff --git a/packages/react-components/react-overflow/library/src/useOverflowSnapshot.ts b/packages/react-components/react-overflow/library/src/useOverflowSnapshot.ts index 7e707dacbe4cee..03929d9af0b3c1 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowSnapshot.ts +++ b/packages/react-components/react-overflow/library/src/useOverflowSnapshot.ts @@ -1,23 +1,10 @@ 'use client'; import type { OverflowSnapshot } from '@fluentui/priority-overflow'; -// import { useIsomorphicLayoutEffect } from '@fluentui/react-utilities'; -// import { flushSync } from 'react-dom'; -import { useOverflowContext } from './overflowContext'; import * as React from 'react'; +import { useOverflowContext } from './overflowContext'; -export const useOverflowSnapshot = (): OverflowSnapshot => { +export const useSyncOverflowSnapshot = (): OverflowSnapshot => { const { getSnapshot, subscribe } = useOverflowContext(); - // const [value, setValue] = React.useState(() => getSnapshot()); - // useIsomorphicLayoutEffect( - // () => - // subscribe(() => { - // flushSync(() => { - // setValue(getSnapshot()); - // }); - // }), - // [getSnapshot, subscribe], - // ); - // return value; return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot); }; diff --git a/packages/react-components/react-overflow/library/src/useOverflowVisibility.ts b/packages/react-components/react-overflow/library/src/useOverflowVisibility.ts index de98d7c05cd3e9..c6764ee8694874 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowVisibility.ts +++ b/packages/react-components/react-overflow/library/src/useOverflowVisibility.ts @@ -1,7 +1,7 @@ 'use client'; import * as React from 'react'; -import { useOverflowSnapshot } from './useOverflowSnapshot'; +import { useSyncOverflowSnapshot } from './useOverflowSnapshot'; import type { OverflowGroupState } from '@fluentui/priority-overflow'; /** @@ -17,6 +17,6 @@ export function useOverflowVisibility(): { itemVisibility: Record; groupVisibility: Record; } { - const { itemVisibility, groupVisibility } = useOverflowSnapshot(); + const { itemVisibility, groupVisibility } = useSyncOverflowSnapshot(); return React.useMemo(() => ({ itemVisibility, groupVisibility }), [itemVisibility, groupVisibility]); } From 052cd842d098caa943751349639665b13bb1358a Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Wed, 27 May 2026 08:41:49 +0200 Subject: [PATCH 11/14] chore: revert breaking changes in the API --- .../etc/priority-overflow.api.md | 14 +++++++- .../priority-overflow/src/overflowManager.ts | 30 +++++++++++++++-- .../priority-overflow/src/types.ts | 32 +++++++++++++++++++ .../library/etc/react-overflow.api.md | 2 ++ .../library/src/components/Overflow.tsx | 28 ++++++++++------ .../react-overflow/library/src/types.ts | 9 +++++- .../library/src/useOverflowContainer.ts | 1 + 7 files changed, 101 insertions(+), 15 deletions(-) diff --git a/packages/react-components/priority-overflow/etc/priority-overflow.api.md b/packages/react-components/priority-overflow/etc/priority-overflow.api.md index 36cb1bdc65c0e7..0b8cd0eee32bab 100644 --- a/packages/react-components/priority-overflow/etc/priority-overflow.api.md +++ b/packages/react-components/priority-overflow/etc/priority-overflow.api.md @@ -5,7 +5,7 @@ ```ts // @internal (undocumented) -export function createOverflowManager(initialOptions: Required): OverflowManager; +export function createOverflowManager(initialOptions?: Partial): OverflowManager; // @public (undocumented) export interface ObserveOptions { @@ -70,13 +70,25 @@ export interface OverflowItemEntry { // @internal (undocumented) export interface OverflowManager { + // @deprecated (undocumented) + addDivider: (divider: OverflowDividerEntry) => void; + // @deprecated (undocumented) + addItem: (item: OverflowItemEntry) => void; + // @deprecated (undocumented) + addOverflowMenu: (element: HTMLElement) => void; attachOverflowMenu: (element: HTMLElement) => () => void; + // @deprecated (undocumented) + disconnect: () => void; forceUpdate: () => void; getSnapshot: () => OverflowSnapshot; observe: (container: HTMLElement) => () => void; registerDivider: (divider: OverflowDividerEntry) => () => void; registerItem: (items: OverflowItemEntry) => () => void; + // @deprecated (undocumented) + removeDivider: (groupId: string) => void; removeItem: (itemId: string) => void; + // @deprecated (undocumented) + removeOverflowMenu: () => void; setOptions: (options: Partial) => void; subscribe: (listener: () => void) => () => void; update: () => void; diff --git a/packages/react-components/priority-overflow/src/overflowManager.ts b/packages/react-components/priority-overflow/src/overflowManager.ts index b4975535d36e79..46a01b603c2143 100644 --- a/packages/react-components/priority-overflow/src/overflowManager.ts +++ b/packages/react-components/priority-overflow/src/overflowManager.ts @@ -12,11 +12,21 @@ import type { OverflowDividerEntry, } from './types'; +const DEFAULT_OPTIONS: Required = { + overflowAxis: 'horizontal', + overflowDirection: 'end', + padding: 10, + minimumVisible: 0, + hasHiddenItems: false, + onUpdateItemVisibility: () => null, + onUpdateOverflow: () => null, +}; + /** * @internal * @returns overflow manager instance */ -export function createOverflowManager(initialOptions: Required): OverflowManager { +export function createOverflowManager(initialOptions: Partial = {}): OverflowManager { // calls to `offsetWidth or offsetHeight` can happen multiple times in an update // Use a cache to avoid causing too many recalcs and avoid scripting time to meausure sizes const sizeCache = new Map(); @@ -27,7 +37,7 @@ export function createOverflowManager(initialOptions: Required): // If true, next update will dispatch to onUpdateOverflow even if queue top states don't change // Initially true to force dispatch on first mount let forceDispatch = true; - const options: Required = initialOptions; + const options: Required = { ...DEFAULT_OPTIONS, ...initialOptions }; const overflowItems: Record = {}; const overflowDividers: Record = {}; const listeners = new Set<() => void>(); @@ -286,6 +296,8 @@ export function createOverflowManager(initialOptions: Required): } }; + let observeCleanup: () => void = () => null; + const observe: OverflowManager['observe'] = observedContainer => { Object.values(overflowItems).forEach(item => { if (!visibleItemQueue.contains(item.id) && !invisibleItemQueue.contains(item.id)) { @@ -295,7 +307,7 @@ export function createOverflowManager(initialOptions: Required): connectContainer(observedContainer); - return () => { + const cleanup = () => { if (container !== observedContainer) { return; } @@ -308,8 +320,13 @@ export function createOverflowManager(initialOptions: Required): resetSnapshot(); notify(); }; + + observeCleanup = cleanup; + return cleanup; }; + const disconnect: OverflowManager['disconnect'] = () => observeCleanup(); + const addItem = (item: OverflowItemEntry) => { if (overflowItems[item.id]) { return; @@ -447,6 +464,13 @@ export function createOverflowManager(initialOptions: Required): setOptions, subscribe, update, + // deprecated backward-compat methods + addItem, + addDivider, + addOverflowMenu, + disconnect, + removeDivider, + removeOverflowMenu, }; } diff --git a/packages/react-components/priority-overflow/src/types.ts b/packages/react-components/priority-overflow/src/types.ts index 153db5ddb9ba82..273fc387657a39 100644 --- a/packages/react-components/priority-overflow/src/types.ts +++ b/packages/react-components/priority-overflow/src/types.ts @@ -157,4 +157,36 @@ export interface OverflowManager { * Subscribes to snapshot changes. */ subscribe: (listener: () => void) => () => void; + + // --- Deprecated backward-compat methods --- + + /** + * @deprecated Use `registerItem` instead — it returns a cleanup function. + */ + addItem: (item: OverflowItemEntry) => void; + + /** + * @deprecated Use `registerDivider` instead — it returns a cleanup function. + */ + addDivider: (divider: OverflowDividerEntry) => void; + + /** + * @deprecated Use `attachOverflowMenu` instead — it returns a cleanup function. + */ + addOverflowMenu: (element: HTMLElement) => void; + + /** + * @deprecated Call the cleanup function returned by `observe` instead. + */ + disconnect: () => void; + + /** + * @deprecated Call the cleanup function returned by `registerDivider` instead. + */ + removeDivider: (groupId: string) => void; + + /** + * @deprecated Call the cleanup function returned by `attachOverflowMenu` instead. + */ + removeOverflowMenu: () => void; } diff --git a/packages/react-components/react-overflow/library/etc/react-overflow.api.md b/packages/react-components/react-overflow/library/etc/react-overflow.api.md index fee1debc222623..9b9e0c04ce441c 100644 --- a/packages/react-components/react-overflow/library/etc/react-overflow.api.md +++ b/packages/react-components/react-overflow/library/etc/react-overflow.api.md @@ -75,6 +75,8 @@ export const useOverflowContainer: (update: OnUpda // @internal (undocumented) export interface UseOverflowContainerReturn extends Pick { containerRef: React_2.RefCallback; + // @deprecated + containerRefObject: React_2.RefObject; manager: OverflowManager | null; } diff --git a/packages/react-components/react-overflow/library/src/components/Overflow.tsx b/packages/react-components/react-overflow/library/src/components/Overflow.tsx index ec6055a31962c4..1d9b3b31a01da9 100644 --- a/packages/react-components/react-overflow/library/src/components/Overflow.tsx +++ b/packages/react-components/react-overflow/library/src/components/Overflow.tsx @@ -67,15 +67,22 @@ export const Overflow = React.forwardRef((props: OverflowProps, ref) => { onOverflowChange?.(null, { ...newState }); }; - const { containerRef, manager, registerItem, updateOverflow, registerOverflowMenu, registerDivider } = - useOverflowContainer(update, { - overflowDirection, - overflowAxis, - padding, - minimumVisible, - hasHiddenItems, - onUpdateItemVisibility: updateVisibilityAttribute, - }); + const { + containerRef, + containerRefObject, + manager, + registerItem, + updateOverflow, + registerOverflowMenu, + registerDivider, + } = useOverflowContainer(update, { + overflowDirection, + overflowAxis, + padding, + minimumVisible, + hasHiddenItems, + onUpdateItemVisibility: updateVisibilityAttribute, + }); const child = getTriggerChild(children); const clonedChild = applyTriggerPropsToChildren(children, { @@ -92,10 +99,11 @@ export const Overflow = React.forwardRef((props: OverflowProps, ref) => { updateOverflow, registerOverflowMenu, registerDivider, + containerRef: containerRefObject, getSnapshot: manager!.getSnapshot, subscribe: manager!.subscribe, }), - [manager, registerItem, updateOverflow, registerOverflowMenu, registerDivider], + [manager, registerItem, updateOverflow, registerOverflowMenu, registerDivider, containerRefObject], ); return {clonedChild}; diff --git a/packages/react-components/react-overflow/library/src/types.ts b/packages/react-components/react-overflow/library/src/types.ts index 24e89007bc1a1a..f77a76194c748b 100644 --- a/packages/react-components/react-overflow/library/src/types.ts +++ b/packages/react-components/react-overflow/library/src/types.ts @@ -8,10 +8,17 @@ import type { OverflowManager } from '@fluentui/priority-overflow'; export interface UseOverflowContainerReturn extends Pick { /** - * Ref to apply to the container that will overflow + * Ref callback to apply to the container that will overflow */ containerRef: React.RefCallback; + /** + * RefObject pointing to the currently observed container element. + * Use this when you need to read `containerRef.current` (e.g. for MutationObserver). + * @deprecated Prefer `containerRef` (RefCallback). This exists for backward compatibility. + */ + containerRefObject: React.RefObject; + /** * Canonical overflow manager for the current container. */ diff --git a/packages/react-components/react-overflow/library/src/useOverflowContainer.ts b/packages/react-components/react-overflow/library/src/useOverflowContainer.ts index 67168274703421..2b6a391f8f1f69 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowContainer.ts +++ b/packages/react-components/react-overflow/library/src/useOverflowContainer.ts @@ -134,6 +134,7 @@ export const useOverflowContainer = ( registerOverflowMenu, updateOverflow, containerRef, + containerRefObject: observedContainerRef, // eslint-disable-next-line react-hooks/refs manager: overflowManagerRef.current, }; From 66561990bf06ca95492ff4bdeeff2418b78260ea Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Wed, 27 May 2026 12:01:00 +0200 Subject: [PATCH 12/14] chore(react-overflow): avoid API breaking changes + cleanup of the restructuring --- .../etc/priority-overflow.api.md | 54 ++----- .../priority-overflow/src/index.ts | 6 +- .../src/overflowManager.test.ts | 20 +-- .../priority-overflow/src/overflowManager.ts | 121 +++++--------- .../priority-overflow/src/types.ts | 149 +++++++++++------- .../library/etc/react-overflow.api.md | 6 +- .../library/src/components/Overflow.tsx | 69 ++++---- .../library/src/overflowContext.ts | 9 +- .../react-overflow/library/src/types.ts | 9 +- .../library/src/useIsOverflowGroupVisible.ts | 4 +- .../library/src/useIsOverflowItemVisible.ts | 4 +- .../library/src/useOverflowContainer.test.ts | 24 +-- .../library/src/useOverflowContainer.ts | 123 ++++++++------- .../library/src/useOverflowCount.ts | 4 +- .../library/src/useOverflowSnapshot.ts | 13 +- .../src/useOverflowVisibility.test.tsx | 20 ++- .../library/src/useOverflowVisibility.ts | 9 +- 17 files changed, 315 insertions(+), 329 deletions(-) diff --git a/packages/react-components/priority-overflow/etc/priority-overflow.api.md b/packages/react-components/priority-overflow/etc/priority-overflow.api.md index 0b8cd0eee32bab..6a6782cb85ea2d 100644 --- a/packages/react-components/priority-overflow/etc/priority-overflow.api.md +++ b/packages/react-components/priority-overflow/etc/priority-overflow.api.md @@ -4,10 +4,10 @@ ```ts -// @internal (undocumented) +// @internal export function createOverflowManager(initialOptions?: Partial): OverflowManager; -// @public (undocumented) +// @public export interface ObserveOptions { hasHiddenItems?: boolean; minimumVisible?: number; @@ -18,97 +18,67 @@ export interface ObserveOptions { padding?: number; } -// @public (undocumented) +// @public export type OnUpdateItemVisibility = (data: OnUpdateItemVisibilityPayload) => void; -// @public (undocumented) +// @public export interface OnUpdateItemVisibilityPayload { - // (undocumented) item: OverflowItemEntry; - // (undocumented) visible: boolean; } // @public export type OnUpdateOverflow = (data: OverflowEventPayload) => void; -// @public (undocumented) +// @public export type OverflowAxis = 'horizontal' | 'vertical'; -// @public (undocumented) +// @public export type OverflowDirection = 'start' | 'end'; -// @public (undocumented) +// @public export interface OverflowDividerEntry { element: HTMLElement; - // (undocumented) groupId: string; } // @public export interface OverflowEventPayload { - // (undocumented) groupVisibility: Record; - // (undocumented) invisibleItems: OverflowItemEntry[]; - // (undocumented) visibleItems: OverflowItemEntry[]; } -// @public (undocumented) +// @public export type OverflowGroupState = 'visible' | 'hidden' | 'overflow'; -// @public (undocumented) +// @public export interface OverflowItemEntry { element: HTMLElement; - // (undocumented) groupId?: string; id: string; pinned?: boolean; priority: number; } -// @internal (undocumented) +// @internal export interface OverflowManager { - // @deprecated (undocumented) addDivider: (divider: OverflowDividerEntry) => void; - // @deprecated (undocumented) addItem: (item: OverflowItemEntry) => void; - // @deprecated (undocumented) addOverflowMenu: (element: HTMLElement) => void; - attachOverflowMenu: (element: HTMLElement) => () => void; - // @deprecated (undocumented) disconnect: () => void; forceUpdate: () => void; - getSnapshot: () => OverflowSnapshot; + getSnapshot: () => OverflowEventPayload; observe: (container: HTMLElement) => () => void; - registerDivider: (divider: OverflowDividerEntry) => () => void; - registerItem: (items: OverflowItemEntry) => () => void; - // @deprecated (undocumented) removeDivider: (groupId: string) => void; removeItem: (itemId: string) => void; - // @deprecated (undocumented) removeOverflowMenu: () => void; setOptions: (options: Partial) => void; subscribe: (listener: () => void) => () => void; update: () => void; } -// @public (undocumented) +// @public export type OverflowManagerOptions = ObserveOptions; -// @public (undocumented) -export interface OverflowSnapshot { - // (undocumented) - groupVisibility: Record; - // (undocumented) - hasOverflow: boolean; - // (undocumented) - itemVisibility: Record; - // (undocumented) - overflowCount: number; -} - -// (No @packageDocumentation comment for this package) - ``` diff --git a/packages/react-components/priority-overflow/src/index.ts b/packages/react-components/priority-overflow/src/index.ts index 37b2890ba04d78..73e9410453b500 100644 --- a/packages/react-components/priority-overflow/src/index.ts +++ b/packages/react-components/priority-overflow/src/index.ts @@ -1,3 +1,8 @@ +/** + * Utilities for measuring container overflow and managing priority-based item visibility. + * + * @packageDocumentation + */ export { createOverflowManager } from './overflowManager'; export type { ObserveOptions, @@ -9,7 +14,6 @@ export type { OverflowDirection, OverflowEventPayload, OverflowGroupState, - OverflowSnapshot, OverflowItemEntry, OverflowDividerEntry, OverflowManager, diff --git a/packages/react-components/priority-overflow/src/overflowManager.test.ts b/packages/react-components/priority-overflow/src/overflowManager.test.ts index b942339a7bc375..dd8f812c97d250 100644 --- a/packages/react-components/priority-overflow/src/overflowManager.test.ts +++ b/packages/react-components/priority-overflow/src/overflowManager.test.ts @@ -53,9 +53,9 @@ describe('overflowManager', () => { const menu = createElementWithSize('button', 20); manager.setOptions(createObserveOptions()); - manager.registerItem({ element: itemA, id: 'a', priority: 1 }); - manager.registerItem({ element: itemB, id: 'b', priority: 0 }); - manager.attachOverflowMenu(menu); + manager.addItem({ element: itemA, id: 'a', priority: 1 }); + manager.addItem({ element: itemB, id: 'b', priority: 0 }); + manager.addOverflowMenu(menu); manager.observe(container); manager.forceUpdate(); @@ -76,9 +76,9 @@ describe('overflowManager', () => { const listener = jest.fn(); manager.setOptions(createObserveOptions()); - manager.registerItem({ element: itemA, id: 'a', priority: 1 }); - manager.registerItem({ element: itemB, id: 'b', priority: 0 }); - manager.attachOverflowMenu(menu); + manager.addItem({ element: itemA, id: 'a', priority: 1 }); + manager.addItem({ element: itemB, id: 'b', priority: 0 }); + manager.addOverflowMenu(menu); manager.observe(container); manager.forceUpdate(); const unsubscribe = manager.subscribe(listener); @@ -102,7 +102,7 @@ describe('overflowManager', () => { const item = createElementWithSize('button', 40); manager.setOptions(createObserveOptions()); - manager.registerItem({ element: item, id: 'a', priority: 1 }); + manager.addItem({ element: item, id: 'a', priority: 1 }); const cleanup = manager.observe(container); manager.forceUpdate(); @@ -118,19 +118,19 @@ describe('overflowManager', () => { }); }); - it('should remove items through registration cleanup', () => { + it('should remove items through removeItem', () => { const manager = createOverflowManager(); const container = createContainer(100); const item = createElementWithSize('button', 40); manager.setOptions(createObserveOptions()); - const cleanupItem = manager.registerItem({ element: item, id: 'a', priority: 1 }); + manager.addItem({ element: item, id: 'a', priority: 1 }); manager.observe(container); manager.forceUpdate(); expect(manager.getSnapshot().itemVisibility).toEqual({ a: true }); - cleanupItem(); + manager.removeItem('a'); manager.forceUpdate(); expect(manager.getSnapshot()).toEqual({ diff --git a/packages/react-components/priority-overflow/src/overflowManager.ts b/packages/react-components/priority-overflow/src/overflowManager.ts index 46a01b603c2143..420ed22ca6a845 100644 --- a/packages/react-components/priority-overflow/src/overflowManager.ts +++ b/packages/react-components/priority-overflow/src/overflowManager.ts @@ -7,9 +7,9 @@ import type { OverflowGroupState, OverflowItemEntry, OverflowManager, - OverflowSnapshot, ObserveOptions, OverflowDividerEntry, + OverflowEventPayload, } from './types'; const DEFAULT_OPTIONS: Required = { @@ -23,7 +23,10 @@ const DEFAULT_OPTIONS: Required = { }; /** + * Creates an overflow manager instance for a single container. + * * @internal + * @param initialOptions - Initial observe options. Missing values are filled with defaults. * @returns overflow manager instance */ export function createOverflowManager(initialOptions: Partial = {}): OverflowManager { @@ -41,15 +44,16 @@ export function createOverflowManager(initialOptions: Partial = const overflowItems: Record = {}; const overflowDividers: Record = {}; const listeners = new Set<() => void>(); - let snapshot: OverflowSnapshot = { - hasOverflow: false, - overflowCount: 0, - itemVisibility: {}, - groupVisibility: {}, - }; let disposeResizeObserver: () => void = () => null; - const notify = () => { + let snapshot: OverflowEventPayload = { + visibleItems: [], + invisibleItems: [], + groupVisibility: {}, + }; + const takeSnapshot = (nextSnapshot: OverflowEventPayload) => { + snapshot = nextSnapshot; + options.onUpdateOverflow(snapshot); listeners.forEach(listener => listener()); }; @@ -158,27 +162,15 @@ export function createOverflowManager(initialOptions: Partial = const dispatchOverflowUpdate = () => { const visibleItemIds = visibleItemQueue.all(); const invisibleItemIds = invisibleItemQueue.all(); - const itemVisibility: Record = {}; const visibleItems = visibleItemIds.map(itemId => overflowItems[itemId]); const invisibleItems = invisibleItemIds.map(itemId => overflowItems[itemId]); - visibleItemIds.forEach(itemId => { - itemVisibility[itemId] = true; + takeSnapshot({ + visibleItems, + invisibleItems, + groupVisibility: groupManager.groupVisibility(), }); - invisibleItemIds.forEach(itemId => { - itemVisibility[itemId] = false; - }); - - snapshot = { - hasOverflow: invisibleItemIds.length > 0, - overflowCount: invisibleItemIds.length, - itemVisibility, - groupVisibility: { ...groupManager.groupVisibility() }, - }; - - options.onUpdateOverflow({ visibleItems, invisibleItems, groupVisibility: groupManager.groupVisibility() }); - notify(); }; const getSnapshot: OverflowManager['getSnapshot'] = () => snapshot; @@ -317,8 +309,11 @@ export function createOverflowManager(initialOptions: Partial = container = undefined; observing = false; forceDispatch = true; - resetSnapshot(); - notify(); + takeSnapshot({ + visibleItems: [], + invisibleItems: [], + groupVisibility: {}, + }); }; observeCleanup = cleanup; @@ -341,20 +336,22 @@ export function createOverflowManager(initialOptions: Partial = // force a dispatch on the next batched update forceDispatch = true; visibleItemQueue.enqueue(item.id); + update(); } if (item.groupId) { groupManager.addItem(item.id, item.groupId); item.element.setAttribute(DATA_OVERFLOW_GROUP, item.groupId); } - - update(); }; const addOverflowMenu = (el: HTMLElement) => { overflowMenu = el; - forceDispatch = true; - update(); + + if (observing) { + forceDispatch = true; + update(); + } }; const addDivider = (divider: OverflowDividerEntry) => { @@ -368,8 +365,11 @@ export function createOverflowManager(initialOptions: Partial = const removeOverflowMenu = () => { overflowMenu = undefined; - forceDispatch = true; - update(); + + if (observing) { + forceDispatch = true; + update(); + } }; const removeDivider = (groupId: string) => { @@ -405,44 +405,9 @@ export function createOverflowManager(initialOptions: Partial = sizeCache.delete(item.element); delete overflowItems[itemId]; - update(); - }; - - const registerItem: OverflowManager['registerItem'] = item => { - addItem(item); - - return () => { - removeItem(item.id); - }; - }; - - const attachOverflowMenu: OverflowManager['attachOverflowMenu'] = element => { - addOverflowMenu(element); - - return () => { - if (overflowMenu === element) { - removeOverflowMenu(); - } - }; - }; - - const registerDivider: OverflowManager['registerDivider'] = divider => { - addDivider(divider); - - return () => { - if (overflowDividers[divider.groupId]?.element === divider.element) { - removeDivider(divider.groupId); - } - }; - }; - - const resetSnapshot = () => { - snapshot = { - hasOverflow: false, - overflowCount: 0, - itemVisibility: {}, - groupVisibility: {}, - }; + if (observing) { + update(); + } }; const subscribe: OverflowManager['subscribe'] = listener => { @@ -454,23 +419,19 @@ export function createOverflowManager(initialOptions: Partial = }; return { - attachOverflowMenu, + addDivider, + addItem, + addOverflowMenu, + disconnect, forceUpdate, getSnapshot, observe, + removeDivider, removeItem, - registerDivider, - registerItem, + removeOverflowMenu, setOptions, subscribe, update, - // deprecated backward-compat methods - addItem, - addDivider, - addOverflowMenu, - disconnect, - removeDivider, - removeOverflowMenu, }; } diff --git a/packages/react-components/priority-overflow/src/types.ts b/packages/react-components/priority-overflow/src/types.ts index 273fc387657a39..02e8439ffea389 100644 --- a/packages/react-components/priority-overflow/src/types.ts +++ b/packages/react-components/priority-overflow/src/types.ts @@ -1,29 +1,39 @@ +/** + * Direction where items are removed when overflow occurs. + */ export type OverflowDirection = 'start' | 'end'; + +/** + * Axis used to measure overflow. + */ export type OverflowAxis = 'horizontal' | 'vertical'; -export type OverflowGroupState = 'visible' | 'hidden' | 'overflow'; -export interface OverflowSnapshot { - hasOverflow: boolean; - overflowCount: number; - itemVisibility: Record; - groupVisibility: Record; -} +/** + * Visibility state for an overflow group. + */ +export type OverflowGroupState = 'visible' | 'hidden' | 'overflow'; +/** + * Tracked item in the overflow manager. + */ export interface OverflowItemEntry { /** - * HTML element that will be disappear when overflowed + * HTML element that disappears when the item overflows. */ element: HTMLElement; /** - * Lower priority items are invisible first when the container is overflowed + * Lower-priority items become invisible first when the container overflows. * @default 0 */ priority: number; /** - * Specific id, used to track visibility and provide updates to consumers + * Stable item id used to track visibility and emit updates. */ id: string; + /** + * Optional group id used to coordinate divider and grouped visibility states. + */ groupId?: string; /** @@ -34,81 +44,119 @@ export interface OverflowItemEntry { pinned?: boolean; } +/** + * Tracked divider in the overflow manager. + */ export interface OverflowDividerEntry { /** - * HTML element that will disappear when overflowed + * HTML element that disappears when its group overflows. */ element: HTMLElement; + /** + * Id of the group controlled by this divider. + */ groupId: string; } /** - * signature similar to standard event listeners, but typed to handle the custom event + * Signature similar to standard event listeners, typed for overflow updates. */ export type OnUpdateOverflow = (data: OverflowEventPayload) => void; +/** + * Callback invoked when a single item's visibility changes. + */ export type OnUpdateItemVisibility = (data: OnUpdateItemVisibilityPayload) => void; /** * Payload of the custom DOM event for overflow updates */ export interface OverflowEventPayload { + /** + * Items currently visible in the container. + */ visibleItems: OverflowItemEntry[]; + + /** + * Items currently moved to overflow. + */ invisibleItems: OverflowItemEntry[]; + + /** + * Current visibility state by group id. + */ groupVisibility: Record; } +/** + * Payload for item-level visibility updates. + */ export interface OnUpdateItemVisibilityPayload { + /** + * Item whose visibility changed. + */ item: OverflowItemEntry; + + /** + * Whether the item is now visible. + */ visible: boolean; } +/** + * Options used to initialize or reconfigure overflow observation. + */ export interface ObserveOptions { /** - * Padding (in px) at the end of the container before overflow occurs - * Useful to account for extra elements (i.e. dropdown menu) - * or to account for any kinds of margins between items which are hard to measure with JS + * Padding in pixels reserved at the end of the container before overflow occurs. + * Useful for accounting for extra elements (for example an overflow menu button) + * or margins between items that are difficult to measure in JavaScript. * @default 10 */ padding?: number; /** - * Direction where items are removed when overflow occurs + * Direction where items are removed when overflow occurs. * @default end */ overflowDirection?: OverflowDirection; /** - * Horizontal or vertical overflow + * Overflow axis used for size measurement. * @default horizontal */ overflowAxis?: OverflowAxis; /** - * The minimum number of visible items + * Minimum number of items that must remain visible. */ minimumVisible?: number; /** - * Callback when item visibility is updated + * Callback invoked when an individual item's visibility changes. */ onUpdateItemVisibility: OnUpdateItemVisibility; /** - * Callback when item visibility is updated + * Callback invoked after overflow state is recomputed. */ onUpdateOverflow: OnUpdateOverflow; /** - * When true, the overflow menu has default hidden items + * When true, reserve space as if the overflow menu were visible even with no overflowing items. * @default false */ hasHiddenItems?: boolean; } +/** + * Runtime options accepted by `setOptions`. + */ export type OverflowManagerOptions = ObserveOptions; /** + * Internal manager contract used to observe and compute priority overflow. + * * @internal */ export interface OverflowManager { @@ -117,76 +165,61 @@ export interface OverflowManager { */ setOptions: (options: Partial) => void; /** - * Starts observing the container and managing the overflow state + * Starts observing the container and managing overflow state. */ observe: (container: HTMLElement) => () => void; /** - * Registers an overflow item and returns a cleanup function. + * Adds an item to overflow tracking. */ - registerItem: (items: OverflowItemEntry) => () => void; + addItem: (item: OverflowItemEntry) => void; + /** - * Remove overflow item + * Removes an overflow item by id. */ removeItem: (itemId: string) => void; /** - * Manually update the overflow, updates are batched and async + * Schedules an asynchronous overflow recomputation. */ update: () => void; /** - * Manually update the overflow sync + * Forces an immediate synchronous overflow recomputation. */ forceUpdate: () => void; /** - * Attaches an element that opens an overflow menu and returns a cleanup function. - * This is used to calculate available space and check if additional items need to overflow. + * Attaches the overflow menu element. + * This is used to calculate available space and determine whether more items should overflow. */ - attachOverflowMenu: (element: HTMLElement) => () => void; - - /** - * Registers a divider and returns a cleanup function. - */ - registerDivider: (divider: OverflowDividerEntry) => () => void; - - /** - * Returns the current canonical overflow snapshot. - */ - getSnapshot: () => OverflowSnapshot; - - /** - * Subscribes to snapshot changes. - */ - subscribe: (listener: () => void) => () => void; - - // --- Deprecated backward-compat methods --- + addOverflowMenu: (element: HTMLElement) => void; /** - * @deprecated Use `registerItem` instead — it returns a cleanup function. + * Removes the overflow menu element. */ - addItem: (item: OverflowItemEntry) => void; + removeOverflowMenu: () => void; /** - * @deprecated Use `registerDivider` instead — it returns a cleanup function. + * Adds a divider for the provided group. */ addDivider: (divider: OverflowDividerEntry) => void; /** - * @deprecated Use `attachOverflowMenu` instead — it returns a cleanup function. + * Removes a divider by group id. */ - addOverflowMenu: (element: HTMLElement) => void; + removeDivider: (groupId: string) => void; /** - * @deprecated Call the cleanup function returned by `observe` instead. + * Returns the current canonical overflow snapshot. */ - disconnect: () => void; + getSnapshot: () => OverflowEventPayload; /** - * @deprecated Call the cleanup function returned by `registerDivider` instead. + * Subscribes to snapshot changes. */ - removeDivider: (groupId: string) => void; + subscribe: (listener: () => void) => () => void; /** - * @deprecated Call the cleanup function returned by `attachOverflowMenu` instead. + * Disconnects all active observers. + * Equivalent to calling the latest cleanup function returned by `observe`. */ - removeOverflowMenu: () => void; + disconnect: () => void; } diff --git a/packages/react-components/react-overflow/library/etc/react-overflow.api.md b/packages/react-components/react-overflow/library/etc/react-overflow.api.md index 9b9e0c04ce441c..974e08c1f7d5f8 100644 --- a/packages/react-components/react-overflow/library/etc/react-overflow.api.md +++ b/packages/react-components/react-overflow/library/etc/react-overflow.api.md @@ -7,10 +7,10 @@ import type { ObserveOptions } from '@fluentui/priority-overflow'; import type { OnUpdateOverflow } from '@fluentui/priority-overflow'; import type { OverflowDividerEntry } from '@fluentui/priority-overflow'; +import type { OverflowEventPayload } from '@fluentui/priority-overflow'; import type { OverflowGroupState } from '@fluentui/priority-overflow'; import type { OverflowItemEntry } from '@fluentui/priority-overflow'; import type { OverflowManager } from '@fluentui/priority-overflow'; -import type { OverflowSnapshot } from '@fluentui/priority-overflow'; import * as React_2 from 'react'; // @public (undocumented) @@ -74,9 +74,7 @@ export const useOverflowContainer: (update: OnUpda // @internal (undocumented) export interface UseOverflowContainerReturn extends Pick { - containerRef: React_2.RefCallback; - // @deprecated - containerRefObject: React_2.RefObject; + containerRef: React_2.RefObject; manager: OverflowManager | null; } diff --git a/packages/react-components/react-overflow/library/src/components/Overflow.tsx b/packages/react-components/react-overflow/library/src/components/Overflow.tsx index 1d9b3b31a01da9..4192befdbfab0a 100644 --- a/packages/react-components/react-overflow/library/src/components/Overflow.tsx +++ b/packages/react-components/react-overflow/library/src/components/Overflow.tsx @@ -2,7 +2,12 @@ import * as React from 'react'; import { mergeClasses } from '@griffel/react'; -import type { ObserveOptions, OnUpdateOverflow, OverflowGroupState } from '@fluentui/priority-overflow'; +import type { + ObserveOptions, + OnUpdateOverflow, + OverflowEventPayload, + OverflowGroupState, +} from '@fluentui/priority-overflow'; import { applyTriggerPropsToChildren, getTriggerChild, @@ -52,37 +57,21 @@ export const Overflow = React.forwardRef((props: OverflowProps, ref) => { } = props; const update: OnUpdateOverflow = data => { - const { visibleItems, invisibleItems, groupVisibility } = data; - - const itemVisibility: Record = {}; - visibleItems.forEach(item => { - itemVisibility[item.id] = true; - }); - invisibleItems.forEach(x => (itemVisibility[x.id] = false)); - const newState = { - hasOverflow: data.invisibleItems.length > 0, - itemVisibility, - groupVisibility, - }; - onOverflowChange?.(null, { ...newState }); + if (!onOverflowChange) { + return; + } + onOverflowChange(null, _overflowPayloadToState(data)); }; - const { - containerRef, - containerRefObject, - manager, - registerItem, - updateOverflow, - registerOverflowMenu, - registerDivider, - } = useOverflowContainer(update, { - overflowDirection, - overflowAxis, - padding, - minimumVisible, - hasHiddenItems, - onUpdateItemVisibility: updateVisibilityAttribute, - }); + const { containerRef, manager, registerItem, updateOverflow, registerOverflowMenu, registerDivider } = + useOverflowContainer(update, { + overflowDirection, + overflowAxis, + padding, + minimumVisible, + hasHiddenItems, + onUpdateItemVisibility: updateVisibilityAttribute, + }); const child = getTriggerChild(children); const clonedChild = applyTriggerPropsToChildren(children, { @@ -99,12 +88,28 @@ export const Overflow = React.forwardRef((props: OverflowProps, ref) => { updateOverflow, registerOverflowMenu, registerDivider, - containerRef: containerRefObject, + containerRef, getSnapshot: manager!.getSnapshot, subscribe: manager!.subscribe, }), - [manager, registerItem, updateOverflow, registerOverflowMenu, registerDivider, containerRefObject], + [manager, registerItem, updateOverflow, registerOverflowMenu, registerDivider], ); return {clonedChild}; }); + +/** + * @internal + */ +export const _overflowPayloadToState = (data: OverflowEventPayload): OverflowState => { + const itemVisibility: Record = {}; + data.visibleItems.forEach(item => { + itemVisibility[item.id] = true; + }); + data.invisibleItems.forEach(x => (itemVisibility[x.id] = false)); + return { + itemVisibility, + groupVisibility: data.groupVisibility, + hasOverflow: data.invisibleItems.length > 0, + }; +}; diff --git a/packages/react-components/react-overflow/library/src/overflowContext.ts b/packages/react-components/react-overflow/library/src/overflowContext.ts index de306113169c23..1f2e4d4a4cecd4 100644 --- a/packages/react-components/react-overflow/library/src/overflowContext.ts +++ b/packages/react-components/react-overflow/library/src/overflowContext.ts @@ -4,7 +4,7 @@ import type { OverflowItemEntry, OverflowDividerEntry, OverflowGroupState, - OverflowSnapshot, + OverflowEventPayload, } from '@fluentui/priority-overflow'; import * as React from 'react'; @@ -29,7 +29,7 @@ export interface OverflowContextValue { registerDivider: (divider: OverflowDividerEntry) => () => void; updateOverflow: (padding?: number) => void; containerRef?: React.RefObject; - getSnapshot: () => OverflowSnapshot; + getSnapshot: () => OverflowEventPayload; subscribe: (listener: () => void) => () => void; } @@ -46,9 +46,8 @@ const overflowContextDefaultValue: OverflowContextValue = { registerOverflowMenu: () => () => null, registerDivider: () => () => null, getSnapshot: () => ({ - hasOverflow: false, - overflowCount: 0, - itemVisibility: {}, + visibleItems: [], + invisibleItems: [], groupVisibility: {}, }), subscribe: () => () => null, diff --git a/packages/react-components/react-overflow/library/src/types.ts b/packages/react-components/react-overflow/library/src/types.ts index f77a76194c748b..4a8740ac0544ef 100644 --- a/packages/react-components/react-overflow/library/src/types.ts +++ b/packages/react-components/react-overflow/library/src/types.ts @@ -10,14 +10,7 @@ export interface UseOverflowContainerReturn /** * Ref callback to apply to the container that will overflow */ - containerRef: React.RefCallback; - - /** - * RefObject pointing to the currently observed container element. - * Use this when you need to read `containerRef.current` (e.g. for MutationObserver). - * @deprecated Prefer `containerRef` (RefCallback). This exists for backward compatibility. - */ - containerRefObject: React.RefObject; + containerRef: React.RefObject; /** * Canonical overflow manager for the current container. diff --git a/packages/react-components/react-overflow/library/src/useIsOverflowGroupVisible.ts b/packages/react-components/react-overflow/library/src/useIsOverflowGroupVisible.ts index 50dab505f49bf0..145bef2063fed2 100644 --- a/packages/react-components/react-overflow/library/src/useIsOverflowGroupVisible.ts +++ b/packages/react-components/react-overflow/library/src/useIsOverflowGroupVisible.ts @@ -1,12 +1,12 @@ 'use client'; import type { OverflowGroupState } from '@fluentui/priority-overflow'; -import { useSyncOverflowSnapshot } from './useOverflowSnapshot'; +import { useOverflowSnapshot } from './useOverflowSnapshot'; /** * @param id - unique identifier for a group of overflow items * @returns visibility state of the group */ export function useIsOverflowGroupVisible(id: string): OverflowGroupState { - return useSyncOverflowSnapshot().groupVisibility[id]; + return useOverflowSnapshot().groupVisibility[id]; } diff --git a/packages/react-components/react-overflow/library/src/useIsOverflowItemVisible.ts b/packages/react-components/react-overflow/library/src/useIsOverflowItemVisible.ts index 9833fc567893e4..f7e066af9fe976 100644 --- a/packages/react-components/react-overflow/library/src/useIsOverflowItemVisible.ts +++ b/packages/react-components/react-overflow/library/src/useIsOverflowItemVisible.ts @@ -1,11 +1,11 @@ 'use client'; -import { useSyncOverflowSnapshot } from './useOverflowSnapshot'; +import { useOverflowSnapshot } from './useOverflowSnapshot'; /** * @param id - unique identifier for the item used by the overflow manager * @returns visibility state of an overflow item */ export function useIsOverflowItemVisible(id: string): boolean { - return !!useSyncOverflowSnapshot().itemVisibility[id]; + return useOverflowSnapshot().visibleItems.some(item => item.id === id); } diff --git a/packages/react-components/react-overflow/library/src/useOverflowContainer.test.ts b/packages/react-components/react-overflow/library/src/useOverflowContainer.test.ts index 5eb7c44fdfb127..e72dd809699864 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowContainer.test.ts +++ b/packages/react-components/react-overflow/library/src/useOverflowContainer.test.ts @@ -7,13 +7,16 @@ jest.mock('@fluentui/priority-overflow'); const mockOverflowManager = (options: Partial = {}) => { const defaultMock: OverflowManager = { - attachOverflowMenu: jest.fn(() => () => undefined), + addDivider: jest.fn(), + addItem: jest.fn(), + addOverflowMenu: jest.fn(), + disconnect: jest.fn(), forceUpdate: jest.fn(), - getSnapshot: jest.fn(() => ({ hasOverflow: false, overflowCount: 0, itemVisibility: {}, groupVisibility: {} })), + getSnapshot: jest.fn(() => ({ visibleItems: [], invisibleItems: [], groupVisibility: {} })), observe: jest.fn(() => () => undefined), - registerDivider: jest.fn(() => () => undefined), - registerItem: jest.fn(() => () => undefined), + removeDivider: jest.fn(), removeItem: jest.fn(), + removeOverflowMenu: jest.fn(), setOptions: jest.fn(), subscribe: jest.fn(() => () => undefined), update: jest.fn(), @@ -32,8 +35,8 @@ describe('useOverflowContainer', () => { }); it('should add to overflow manager when registering item', () => { - const registerItemMock = jest.fn(() => () => undefined); - mockOverflowManager({ registerItem: registerItemMock }); + const addItemMock = jest.fn(); + mockOverflowManager({ addItem: addItemMock }); const { result } = renderHook(() => useOverflowContainer(() => undefined, { onUpdateItemVisibility: () => undefined }), ); @@ -41,14 +44,13 @@ describe('useOverflowContainer', () => { const overflowItem = { element: document.createElement('div'), id: 'test', priority: 0, groupId: '0' }; result.current.registerItem(overflowItem); - expect(registerItemMock).toHaveBeenCalledTimes(1); - expect(registerItemMock).toHaveBeenCalledWith(overflowItem); + expect(addItemMock).toHaveBeenCalledTimes(1); + expect(addItemMock).toHaveBeenCalledWith(overflowItem); }); it('should return cleanup when registering item', () => { const deregisterItemMock = jest.fn(); - const registerItemMock = jest.fn(() => deregisterItemMock); - mockOverflowManager({ registerItem: registerItemMock }); + mockOverflowManager({ removeItem: deregisterItemMock }); const { result } = renderHook(() => useOverflowContainer(() => undefined, { onUpdateItemVisibility: () => undefined }), ); @@ -67,7 +69,7 @@ describe('useOverflowContainer', () => { return useOverflowContainer(() => undefined, { onUpdateItemVisibility: () => undefined }); }); act(() => { - result.current.containerRef(document.createElement('div')); + result.current.containerRef.current = document.createElement('div'); }); rerender(); expect(observeMock).toHaveBeenCalledTimes(1); diff --git a/packages/react-components/react-overflow/library/src/useOverflowContainer.ts b/packages/react-components/react-overflow/library/src/useOverflowContainer.ts index 2b6a391f8f1f69..370b102b87290c 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowContainer.ts +++ b/packages/react-components/react-overflow/library/src/useOverflowContainer.ts @@ -14,7 +14,7 @@ import type { OverflowManager, ObserveOptions, } from '@fluentui/priority-overflow'; -import { canUseDOM, useEventCallback } from '@fluentui/react-utilities'; +import { canUseDOM, useEventCallback, useIsomorphicLayoutEffect } from '@fluentui/react-utilities'; import type { UseOverflowContainerReturn } from './types'; import { DATA_OVERFLOWING, DATA_OVERFLOW_DIVIDER, DATA_OVERFLOW_ITEM, DATA_OVERFLOW_MENU } from './constants'; @@ -65,68 +65,77 @@ export const useOverflowContainer = ( ], ); - const overflowManagerRef = React.useRef(null); - const cleanupObservationRef = React.useRef<(() => void) | null>(null); - const observedContainerRef = React.useRef(null); + const containerRef = React.useRef(null); + const overflowMenuRef = React.useRef(null); + const dividerElementsRef = React.useRef(new Map()); - // eslint-disable-next-line react-hooks/refs - if (!overflowManagerRef.current && canUseDOM()) { - overflowManagerRef.current = createOverflowManager(observeOptions); - } + const manager = React.useMemo( + () => (canUseDOM() ? createOverflowManager(observeOptions) : null), + [], + ); - const containerRef = React.useCallback>(node => { - if (observedContainerRef.current === node) { - return; + useIsomorphicLayoutEffect(() => { + if (manager && containerRef.current) { + return manager.observe(containerRef.current); } + }, [manager]); - cleanupObservationRef.current?.(); - cleanupObservationRef.current = null; - observedContainerRef.current = node; + React.useEffect(() => { + manager?.setOptions(observeOptions); + }, [observeOptions, manager]); + + const registerItem = React.useCallback( + (item: OverflowItemEntry) => { + manager?.addItem(item); + item.element.setAttribute(DATA_OVERFLOW_ITEM, ''); + + return () => { + item.element.removeAttribute(DATA_OVERFLOWING); + item.element.removeAttribute(DATA_OVERFLOW_ITEM); + manager?.removeItem(item.id); + }; + }, + [manager], + ); - if (overflowManagerRef.current && node) { - cleanupObservationRef.current = overflowManagerRef.current.observe(node); - } - }, []); + const registerDivider = React.useCallback( + (divider: OverflowDividerEntry) => { + const el = divider.element; + manager?.addDivider(divider); + dividerElementsRef.current.set(divider.groupId, el); + el.setAttribute(DATA_OVERFLOW_DIVIDER, ''); + + return () => { + if (dividerElementsRef.current.get(divider.groupId) === el) { + manager?.removeDivider(divider.groupId); + dividerElementsRef.current.delete(divider.groupId); + } + el.removeAttribute(DATA_OVERFLOW_DIVIDER); + }; + }, + [manager], + ); - React.useEffect(() => { - overflowManagerRef.current?.setOptions(observeOptions); - }, [observeOptions]); - - const registerItem = React.useCallback((item: OverflowItemEntry) => { - const deregisterItem = overflowManagerRef.current?.registerItem(item) ?? noop; - item.element.setAttribute(DATA_OVERFLOW_ITEM, ''); - - return () => { - item.element.removeAttribute(DATA_OVERFLOWING); - item.element.removeAttribute(DATA_OVERFLOW_ITEM); - deregisterItem(); - }; - }, []); - - const registerDivider = React.useCallback((divider: OverflowDividerEntry) => { - const el = divider.element; - const deregisterDivider = overflowManagerRef.current?.registerDivider(divider) ?? noop; - el.setAttribute(DATA_OVERFLOW_DIVIDER, ''); - - return () => { - deregisterDivider(); - el.removeAttribute(DATA_OVERFLOW_DIVIDER); - }; - }, []); - - const registerOverflowMenu = React.useCallback((el: HTMLElement) => { - const detachOverflowMenu = overflowManagerRef.current?.attachOverflowMenu(el) ?? noop; - el.setAttribute(DATA_OVERFLOW_MENU, ''); - - return () => { - detachOverflowMenu(); - el.removeAttribute(DATA_OVERFLOW_MENU); - }; - }, []); + const registerOverflowMenu = React.useCallback( + (el: HTMLElement) => { + manager?.addOverflowMenu(el); + overflowMenuRef.current = el; + el.setAttribute(DATA_OVERFLOW_MENU, ''); + + return () => { + if (overflowMenuRef.current === el) { + manager?.removeOverflowMenu(); + overflowMenuRef.current = null; + } + el.removeAttribute(DATA_OVERFLOW_MENU); + }; + }, + [manager], + ); const updateOverflow = React.useCallback(() => { - overflowManagerRef.current?.update(); - }, []); + manager?.update(); + }, [manager]); return { registerItem, @@ -134,9 +143,7 @@ export const useOverflowContainer = ( registerOverflowMenu, updateOverflow, containerRef, - containerRefObject: observedContainerRef, - // eslint-disable-next-line react-hooks/refs - manager: overflowManagerRef.current, + manager, }; }; diff --git a/packages/react-components/react-overflow/library/src/useOverflowCount.ts b/packages/react-components/react-overflow/library/src/useOverflowCount.ts index 066bc81e721558..d57225462baf92 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowCount.ts +++ b/packages/react-components/react-overflow/library/src/useOverflowCount.ts @@ -1,8 +1,8 @@ 'use client'; -import { useSyncOverflowSnapshot } from './useOverflowSnapshot'; +import { useOverflowSnapshot } from './useOverflowSnapshot'; /** * @returns Number of items that are overflowing */ -export const useOverflowCount = (): number => useSyncOverflowSnapshot().overflowCount; +export const useOverflowCount = (): number => useOverflowSnapshot().invisibleItems.length; diff --git a/packages/react-components/react-overflow/library/src/useOverflowSnapshot.ts b/packages/react-components/react-overflow/library/src/useOverflowSnapshot.ts index 03929d9af0b3c1..7747c53f69ad43 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowSnapshot.ts +++ b/packages/react-components/react-overflow/library/src/useOverflowSnapshot.ts @@ -1,10 +1,17 @@ 'use client'; -import type { OverflowSnapshot } from '@fluentui/priority-overflow'; +import type { OverflowEventPayload } from '@fluentui/priority-overflow'; import * as React from 'react'; import { useOverflowContext } from './overflowContext'; +import { useIsomorphicLayoutEffect } from '@fluentui/react-utilities'; -export const useSyncOverflowSnapshot = (): OverflowSnapshot => { +export const useOverflowSnapshot = (): OverflowEventPayload => { const { getSnapshot, subscribe } = useOverflowContext(); - return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + const [snapshot, setSnapshot] = React.useState(() => getSnapshot()); + useIsomorphicLayoutEffect(() => { + return subscribe(() => { + setSnapshot(getSnapshot()); + }); + }, [subscribe, getSnapshot]); + return snapshot; }; diff --git a/packages/react-components/react-overflow/library/src/useOverflowVisibility.test.tsx b/packages/react-components/react-overflow/library/src/useOverflowVisibility.test.tsx index db08a94ad2a639..801eab63e07f83 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowVisibility.test.tsx +++ b/packages/react-components/react-overflow/library/src/useOverflowVisibility.test.tsx @@ -18,14 +18,18 @@ describe('useOverflowVisibility', () => { bar: true, baz: false, } as const; + const snapshot = { + hasOverflow: true, + overflowCount: 1, + itemVisibility, + groupVisibility, + }; + const getSnapshot = jest.fn(() => snapshot); + const subscribe = jest.fn(() => () => undefined); + const manager = { - getSnapshot: jest.fn(() => ({ - hasOverflow: true, - overflowCount: 1, - itemVisibility, - groupVisibility, - })), - subscribe: jest.fn(() => () => undefined), + getSnapshot, + subscribe, } as unknown as OverflowManager; const Wrapper: React.FC = props => { return ( @@ -34,6 +38,8 @@ describe('useOverflowVisibility', () => { value={ { manager, + getSnapshot, + subscribe, } as unknown as OverflowContextValue } /> diff --git a/packages/react-components/react-overflow/library/src/useOverflowVisibility.ts b/packages/react-components/react-overflow/library/src/useOverflowVisibility.ts index c6764ee8694874..825a0dbb3a8469 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowVisibility.ts +++ b/packages/react-components/react-overflow/library/src/useOverflowVisibility.ts @@ -1,8 +1,9 @@ 'use client'; -import * as React from 'react'; -import { useSyncOverflowSnapshot } from './useOverflowSnapshot'; import type { OverflowGroupState } from '@fluentui/priority-overflow'; +import * as React from 'react'; +import { _overflowPayloadToState } from './components/Overflow'; +import { useOverflowSnapshot } from './useOverflowSnapshot'; /** * A hook that returns the visibility status of all items and groups. @@ -17,6 +18,6 @@ export function useOverflowVisibility(): { itemVisibility: Record; groupVisibility: Record; } { - const { itemVisibility, groupVisibility } = useSyncOverflowSnapshot(); - return React.useMemo(() => ({ itemVisibility, groupVisibility }), [itemVisibility, groupVisibility]); + const snapshot = useOverflowSnapshot(); + return React.useMemo(() => _overflowPayloadToState(snapshot), [snapshot]); } From df587aafe7215112efd4c6a300bfd06c159a6fe8 Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Thu, 28 May 2026 11:08:26 +0200 Subject: [PATCH 13/14] force update to avoid splitting tasks --- .../library/etc/react-overflow.api.md | 4 +-- .../library/src/components/Overflow.tsx | 33 ++++++++++++------- .../library/src/overflowContext.ts | 2 ++ .../react-overflow/library/src/types.ts | 17 ++++++---- .../library/src/useOverflowContainer.ts | 19 +++++++++-- .../library/src/useOverflowMenu.ts | 8 +++-- 6 files changed, 56 insertions(+), 27 deletions(-) diff --git a/packages/react-components/react-overflow/library/etc/react-overflow.api.md b/packages/react-components/react-overflow/library/etc/react-overflow.api.md index 974e08c1f7d5f8..a1076656fefdff 100644 --- a/packages/react-components/react-overflow/library/etc/react-overflow.api.md +++ b/packages/react-components/react-overflow/library/etc/react-overflow.api.md @@ -10,7 +10,6 @@ import type { OverflowDividerEntry } from '@fluentui/priority-overflow'; import type { OverflowEventPayload } from '@fluentui/priority-overflow'; import type { OverflowGroupState } from '@fluentui/priority-overflow'; import type { OverflowItemEntry } from '@fluentui/priority-overflow'; -import type { OverflowManager } from '@fluentui/priority-overflow'; import * as React_2 from 'react'; // @public (undocumented) @@ -73,9 +72,8 @@ export function useIsOverflowItemVisible(id: string): boolean; export const useOverflowContainer: (update: OnUpdateOverflow, options: Omit) => UseOverflowContainerReturn; // @internal (undocumented) -export interface UseOverflowContainerReturn extends Pick { +export interface UseOverflowContainerReturn extends Pick { containerRef: React_2.RefObject; - manager: OverflowManager | null; } // @internal (undocumented) diff --git a/packages/react-components/react-overflow/library/src/components/Overflow.tsx b/packages/react-components/react-overflow/library/src/components/Overflow.tsx index 4192befdbfab0a..d30fb03bf530f3 100644 --- a/packages/react-components/react-overflow/library/src/components/Overflow.tsx +++ b/packages/react-components/react-overflow/library/src/components/Overflow.tsx @@ -63,15 +63,23 @@ export const Overflow = React.forwardRef((props: OverflowProps, ref) => { onOverflowChange(null, _overflowPayloadToState(data)); }; - const { containerRef, manager, registerItem, updateOverflow, registerOverflowMenu, registerDivider } = - useOverflowContainer(update, { - overflowDirection, - overflowAxis, - padding, - minimumVisible, - hasHiddenItems, - onUpdateItemVisibility: updateVisibilityAttribute, - }); + const { + containerRef, + getSnapshot, + subscribe, + registerItem, + updateOverflow, + forceUpdateOverflow, + registerOverflowMenu, + registerDivider, + } = useOverflowContainer(update, { + overflowDirection, + overflowAxis, + padding, + minimumVisible, + hasHiddenItems, + onUpdateItemVisibility: updateVisibilityAttribute, + }); const child = getTriggerChild(children); const clonedChild = applyTriggerPropsToChildren(children, { @@ -86,13 +94,14 @@ export const Overflow = React.forwardRef((props: OverflowProps, ref) => { hasOverflow: false, registerItem, updateOverflow, + forceUpdateOverflow, registerOverflowMenu, registerDivider, containerRef, - getSnapshot: manager!.getSnapshot, - subscribe: manager!.subscribe, + getSnapshot, + subscribe, }), - [manager, registerItem, updateOverflow, registerOverflowMenu, registerDivider], + [getSnapshot, subscribe, registerItem, updateOverflow, forceUpdateOverflow, registerOverflowMenu, registerDivider], ); return {clonedChild}; diff --git a/packages/react-components/react-overflow/library/src/overflowContext.ts b/packages/react-components/react-overflow/library/src/overflowContext.ts index 1f2e4d4a4cecd4..beeb514a11cd55 100644 --- a/packages/react-components/react-overflow/library/src/overflowContext.ts +++ b/packages/react-components/react-overflow/library/src/overflowContext.ts @@ -28,6 +28,7 @@ export interface OverflowContextValue { registerOverflowMenu: (el: HTMLElement) => () => void; registerDivider: (divider: OverflowDividerEntry) => () => void; updateOverflow: (padding?: number) => void; + forceUpdateOverflow: () => void; containerRef?: React.RefObject; getSnapshot: () => OverflowEventPayload; subscribe: (listener: () => void) => () => void; @@ -43,6 +44,7 @@ const overflowContextDefaultValue: OverflowContextValue = { groupVisibility: {}, registerItem: () => () => null, updateOverflow: () => null, + forceUpdateOverflow: () => null, registerOverflowMenu: () => () => null, registerDivider: () => () => null, getSnapshot: () => ({ diff --git a/packages/react-components/react-overflow/library/src/types.ts b/packages/react-components/react-overflow/library/src/types.ts index 4a8740ac0544ef..92303dddb249af 100644 --- a/packages/react-components/react-overflow/library/src/types.ts +++ b/packages/react-components/react-overflow/library/src/types.ts @@ -1,19 +1,22 @@ import type * as React from 'react'; import type { OverflowContextValue } from './overflowContext'; -import type { OverflowManager } from '@fluentui/priority-overflow'; /** * @internal */ export interface UseOverflowContainerReturn - extends Pick { + extends Pick< + OverflowContextValue, + | 'registerItem' + | 'updateOverflow' + | 'forceUpdateOverflow' + | 'registerOverflowMenu' + | 'registerDivider' + | 'getSnapshot' + | 'subscribe' + > { /** * Ref callback to apply to the container that will overflow */ containerRef: React.RefObject; - - /** - * Canonical overflow manager for the current container. - */ - manager: OverflowManager | null; } diff --git a/packages/react-components/react-overflow/library/src/useOverflowContainer.ts b/packages/react-components/react-overflow/library/src/useOverflowContainer.ts index 370b102b87290c..62590b61673001 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowContainer.ts +++ b/packages/react-components/react-overflow/library/src/useOverflowContainer.ts @@ -76,7 +76,9 @@ export const useOverflowContainer = ( useIsomorphicLayoutEffect(() => { if (manager && containerRef.current) { - return manager.observe(containerRef.current); + const unsubscribe = manager.observe(containerRef.current); + manager.forceUpdate(); + return unsubscribe; } }, [manager]); @@ -137,16 +139,29 @@ export const useOverflowContainer = ( manager?.update(); }, [manager]); + const forceUpdateOverflow = React.useCallback(() => { + manager?.forceUpdate(); + }, [manager]); + return { registerItem, registerDivider, registerOverflowMenu, updateOverflow, + forceUpdateOverflow, containerRef, - manager, + getSnapshot: manager?.getSnapshot ?? defaultGetSnapshot, + subscribe: manager?.subscribe ?? defaultSubscribe, }; }; +const defaultGetSnapshot = () => ({ + visibleItems: [], + invisibleItems: [], + groupVisibility: {}, +}); +const defaultSubscribe = () => () => null; + export const updateVisibilityAttribute: OnUpdateItemVisibility = ({ item, visible }) => { if (visible) { item.element.removeAttribute(DATA_OVERFLOWING); diff --git a/packages/react-components/react-overflow/library/src/useOverflowMenu.ts b/packages/react-components/react-overflow/library/src/useOverflowMenu.ts index f4cc295f7c0053..4b245987c7366f 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowMenu.ts +++ b/packages/react-components/react-overflow/library/src/useOverflowMenu.ts @@ -11,15 +11,17 @@ export function useOverflowMenu( ): { ref: React.MutableRefObject; overflowCount: number; isOverflowing: boolean } { const elementId = useId('overflow-menu', id); const overflowCount = useOverflowCount(); - const { registerOverflowMenu } = useOverflowContext(); + const { registerOverflowMenu, forceUpdateOverflow } = useOverflowContext(); const ref = React.useRef(null); const isOverflowing = overflowCount > 0; useIsomorphicLayoutEffect(() => { if (ref.current) { - return registerOverflowMenu(ref.current); + const unregister = registerOverflowMenu(ref.current); + forceUpdateOverflow(); + return unregister; } - }, [registerOverflowMenu, isOverflowing, elementId]); + }, [registerOverflowMenu, forceUpdateOverflow, isOverflowing, elementId]); return { ref, overflowCount, isOverflowing }; } From efff15f3cae5a6e6cc15cded5ff8977630442ae7 Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Thu, 28 May 2026 11:16:14 +0200 Subject: [PATCH 14/14] test(priority-overflow): update overflowManager tests to match new snapshot shape Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/overflowManager.test.ts | 44 ++++++++++--------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/packages/react-components/priority-overflow/src/overflowManager.test.ts b/packages/react-components/priority-overflow/src/overflowManager.test.ts index dd8f812c97d250..337817257bf199 100644 --- a/packages/react-components/priority-overflow/src/overflowManager.test.ts +++ b/packages/react-components/priority-overflow/src/overflowManager.test.ts @@ -45,6 +45,18 @@ describe('overflowManager', () => { ...options, }); + const getVisibleIds = (manager: ReturnType) => + manager + .getSnapshot() + .visibleItems.map(item => item.id) + .sort(); + + const getInvisibleIds = (manager: ReturnType) => + manager + .getSnapshot() + .invisibleItems.map(item => item.id) + .sort(); + it('should expose a stable snapshot after forceUpdate', () => { const manager = createOverflowManager(); const container = createContainer(100); @@ -59,12 +71,9 @@ describe('overflowManager', () => { manager.observe(container); manager.forceUpdate(); - expect(manager.getSnapshot()).toEqual({ - hasOverflow: false, - overflowCount: 0, - itemVisibility: { a: true, b: true }, - groupVisibility: {}, - }); + expect(getVisibleIds(manager)).toEqual(['a', 'b']); + expect(getInvisibleIds(manager)).toEqual([]); + expect(manager.getSnapshot().groupVisibility).toEqual({}); }); it('should update snapshot and notify subscribers when options change', () => { @@ -86,12 +95,9 @@ describe('overflowManager', () => { manager.setOptions({ padding: 30 }); expect(listener).toHaveBeenCalled(); - expect(manager.getSnapshot()).toEqual({ - hasOverflow: true, - overflowCount: 1, - itemVisibility: { a: true, b: false }, - groupVisibility: {}, - }); + expect(getVisibleIds(manager)).toEqual(['a']); + expect(getInvisibleIds(manager)).toEqual(['b']); + expect(manager.getSnapshot().groupVisibility).toEqual({}); unsubscribe(); }); @@ -106,14 +112,13 @@ describe('overflowManager', () => { const cleanup = manager.observe(container); manager.forceUpdate(); - expect(manager.getSnapshot().itemVisibility).toEqual({ a: true }); + expect(getVisibleIds(manager)).toEqual(['a']); cleanup(); expect(manager.getSnapshot()).toEqual({ - hasOverflow: false, - overflowCount: 0, - itemVisibility: {}, + visibleItems: [], + invisibleItems: [], groupVisibility: {}, }); }); @@ -128,15 +133,14 @@ describe('overflowManager', () => { manager.observe(container); manager.forceUpdate(); - expect(manager.getSnapshot().itemVisibility).toEqual({ a: true }); + expect(getVisibleIds(manager)).toEqual(['a']); manager.removeItem('a'); manager.forceUpdate(); expect(manager.getSnapshot()).toEqual({ - hasOverflow: false, - overflowCount: 0, - itemVisibility: {}, + visibleItems: [], + invisibleItems: [], groupVisibility: {}, }); });