[utils] Port x-internals store improvements - #5489
Conversation
Imports the @mui/x-internals selector implementation so the two store codebases converge: 7-8 input selector support, the createSelectorMemoizedWithOptions factory, the single-combiner identity fix, and Store.create. Adds Store.test.ts covering selector arity, memoization cache-key behavior, and extra-args passing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
commit: |
Bundle size
PerformanceTotal duration: 900.01 ms -57.40 ms(-6.0%) | Renders: 76 (+0) | Paint: 1,437.37 ms -88.74 ms(-5.8%) No significant changes — details Check out the code infra dashboard for more information about this PR. |
✅ Deploy Preview for base-ui ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
Deletes the Store, useStore, and createSelector implementations and re-exports @base-ui/utils/store, which now contains the same selector features (mui/base-ui#5489). useStoreEffect stays local, rebuilt on the public store surface. Adjustments for the stricter @base-ui/utils store typings: - update() takes an exact key subset instead of Partial<State>: cast the accumulated-changes call sites in ChatStore, SchedulerStore, MinimalTreeViewStore, and EventCalendarStore. - The generic set() is no longer callable on a union of store classes: seed errors through a typed helper in dataSource.test.ts. The catalog temporarily points at the pkg.pr.new build of the base-ui PR and must be repointed to a released version before merging. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PR reviewTwo merge-blocking public API issues remain: the new Reselect options parameter is effectively untyped, and Bugs (4)1. 🔴 The options factory exposes the wrong Reselect typeLocation: export const createSelectorMemoizedWithOptions =
(options?: OverrideMemoizeOptions<UnknownMemoizer>): CreateSelectorFunction =>
Failure scenario: An Fix: Type this with an appropriately generic Reselect 2. 🔴
|
- Type createSelectorMemoizedWithOptions with the Reselect CreateSelectorOptions shape, generic over override memoizers, instead of the effectively-untyped OverrideMemoizeOptions<UnknownMemoizer>. - Make Store.create construct the class it is called on, so inherited factories return proper subclass instances. - Bound CreateSelectorFunction to the runtime limits: up to seven input selectors, and up to three extra combiner arguments when the parameter count is statically known. Open-ended parameter tuples (rest params or contextually typed combiners) cannot be distinguished at the type level and remain covered by the runtime guards. - Require the state argument in the single-function form's result type, so a zero-parameter combiner can no longer be invoked without the state object its cache key is stored on. - Add runtime regression tests and a type-level spec for the above. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
All four findings addressed in 9f1f7e4:
Verification: utils typecheck, lint, and 84 store tests pass here; the full mui-x monorepo also typechecks cleanly against the pkg.pr.new build of this commit (mui/mui-x#23335 is now pinned to it), which exercises ~80 selector call sites including the 🤖 Generated with Claude Code |
Only createSelector dispatches through fixed arities capped at seven input selectors; the memoized variant delegates to reselect and has no such limit. CreateSelectorFunction<BoundedSelectors> expresses the difference; MUI X has memoized selectors with eight inputs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PR reviewReviewed at Bugs (5)1. 🟠
|
- Restore the runtime guard for combiners whose Function.length under-reports (rest parameters, wrappers): only a zero-length combiner may ignore its inputs; any other length below the selector count throws instead of silently dropping arguments. Static rejection of rest parameters is not possible: open-ended parameter tuples also occur for contextually-typed combiners, and DropFirst erases open tails behind fixed elements, so the runtime guard covers them. - Merge object-form memoizeOptions over the module defaults so passing options no longer silently discards the Object.is equality check. - Split CreateSelectorFunction into two plain types instead of the BoundedSelectors parameter: createSelector keeps its seven-input bound (matching its unrolled dispatch) and regains master parity for the single-function form (returned verbatim, no state requirement), while CreateSelectorMemoizedFunction is unbounded on inputs and carries the state-required and three-extra-args constraints. - Document that Store.create on a generic base class degrades the inferred type to Store, and pin the degradation in the specs. - Split tests per module, cover the extra-args dispatch paths (argsLength 1-3), the six-selector branch, the options merge, and real Store behavior; document the memoized single-function form's state-identity caching. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
115b60a to
94bb4d4
Compare
|
Review findings addressed in 94bb4d4. Summary per finding, including two deliberate divergences: Bug 1 (runtime guard) — Restored: only a zero-length combiner may ignore its inputs (a deliberate pattern in MUI X's virtualizer); any other length below the selector count throws Bug 2 (options clobbering) — Object-form Bug 3 ( Bug 4 (type tightenings) — Fixed via the type split: Bug 5 + Docs 1/2 — JSDoc added on both memoized exports (state-identity caching of the single-function form, options-merge semantics, Function.length constraints); both incorrect comments rewritten. Tests 1–5 — Split per module ( Simplification 2 (drop the arity cap) — Not taken: Simplification 1 (fallthrough switch) — Not taken for the switch itself (kept intentionally); the leaked suppression is fixed with a matching Simplifications 3/4 — Verification: utils typecheck/lint clean, 101 store tests passing, and all twelve store-consuming MUI X packages typecheck clean against the pkg.pr.new build of this commit (mui/mui-x#23335 is pinned to it). 🤖 Generated with Claude Code |
PR reviewThe author’s response at Bugs (3)1. 🔴 A custom memoizer still receives
|
- createSelectorMemoizedWithOptions: clear memoizeOptions when the caller overrides memoize without supplying its own. reselect shallow-merges the creator options into the call-site ones, so the lruMemoize defaults otherwise reached a custom memoizer that never asked for them, crashing memoizers that default their options parameter to an equality function. - createSelector: apply the three-extra-arguments limit to composed forms. The check only bounded the input selector count, so a combiner declaring a fourth extra argument type-checked while every unrolled branch forwards only a1-a3, leaving it undefined. The single-function form is still returned verbatim and keeps its own signature. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Both fixed in 9481523. Bug 1 (custom memoizer receives Bug 2 (composed form types an argument the runtime drops) — Fixed as suggested: the extra-argument check is now shared between the two variants ( I did not add the runtime guard you floated as optional for Bug 2. Bug 3 (zero-length rest wrappers) — Left as the documented limitation, per your assessment. Verification: utils typecheck and lint clean, 104 store tests passing, no new error codes. Against the pkg.pr.new build of this commit, all twelve store-consuming MUI X packages typecheck clean and the x-charts suite (867 tests, the 🤖 Generated with Claude Code |
michaldudak
left a comment
There was a problem hiding this comment.
PR review
The port behaves as described — I verified the single-combiner fix, the widened arity, and the reselect options plumbing at runtime, and none of them regress anything. Nothing here is merge-blocking; what follows is test coverage and cleanup worth a look, most notably that the test meant to pin the new memoizeOptions merge passes with the merge removed.
Tests (2)
1. 🟠 The memoizeOptions merge test passes with the merge removed
Location: packages/utils/src/store/createSelectorMemoized.test.ts:184
const state: S = { value: NaN };
selector(state);
selector(state);
expect(combiner).toHaveBeenCalledTimes(1);Both calls pass the same state reference, so reselect's argsMemoize (weakMapMemoize) short-circuits on the arguments before the input selectors or the result memoizer run. The combiner is called once regardless of whether equalityCheck: Object.is survived the merge, so the stated assertion proves nothing — and the comment above it ("replacing the default equality with === would re-run the combiner") describes a mechanism that never executes.
I verified this by replacing the merge with resolvedOptions = options. The test does fail — but only because reselect's dev-only inputStabilityCheck emits a console warning that vitest-fail-on-console promotes to a failure. Adding devModeChecks: { inputStabilityCheck: 'never' } (as four neighbouring tests already do) or running under NODE_ENV=production makes it silently vacuous.
Failure scenario: Someone simplifies the option resolution and drops the { ...MEMOIZE_OPTIONS, ...memoizeOptions } merge. Object.is equality is silently replaced by ===, NaN state values stop memoizing for every consumer that passes memoizeOptions, and this test still passes.
Fix: Call the selector with a derived state so the cache key is shared and the result memoizer actually decides. I confirmed this version fails with the merge removed and passes with it:
const selector = createSelectorMemoizedWithOptions({
memoizeOptions: { maxSize: 2 },
devModeChecks: { inputStabilityCheck: 'never', identityFunctionCheck: 'never' },
})((state: S) => state.value, combiner);
const state: S = { value: NaN };
selector(state);
// A derived state shares __cacheKey__, so the reselect instance is reused and the
// memoize equality decides whether the combiner re-runs.
selector({ ...state });
expect(combiner).toHaveBeenCalledTimes(1);2. 🟠 The single-combiner form with extra arguments has no runtime test
Location: packages/utils/src/store/createSelectorMemoized.test.ts:9
The headline fix wires the single-function form as [identity, ...argGetters, combiner]. The runtime tests cover only the zero-extra-arg shapes — (s) => ({ doubled: s.value * 2 }) and () => 42. The path where selectors.slice(0, -1) must yield the identity selector and the arg getters is exercised only by createSelectorMemoized.spec.ts:23, which is a type-level assertion and executes nothing.
That path is exactly what was broken before this PR: with selectors = [combiner], slice(0, -1) was empty and the combiner received (a1) instead of (state, a1). I confirmed it works now — createSelectorMemoized((s, a1) => s.value + a1) returns 15 for (state, 5) and memoizes correctly — but nothing pins it.
Failure scenario: A later refactor of the selectors construction reintroduces the old behavior for the single-combiner-with-args form. The combiner receives a1 as its state parameter, every selector of that shape returns garbage or throws, and the whole suite stays green.
Fix: Add a test alongside the existing single-combiner cases:
it('passes extra arguments to a single combiner alongside the state', () => {
const combiner = vi.fn((s: { value: number }, a1: number) => s.value + a1);
const selector = createSelectorMemoized(combiner);
const state = { value: 10 };
expect(selector(state, 5)).toBe(15);
expect(selector(state, 5)).toBe(15);
expect(combiner).toHaveBeenCalledTimes(1);
expect(selector(state, 6)).toBe(16);
expect(combiner).toHaveBeenCalledTimes(2);
});Simplifications (2)
1. 🟡 The fallthrough switch replaces three unconditional writes without changing behavior
Location: packages/utils/src/store/createSelectorMemoized.ts:128
/* eslint-disable no-fallthrough */
switch (argsLength) {
case 3:
fn.selectorArgs[2] = a3;
case 2:
fn.selectorArgs[1] = a2;
case 1:
fn.selectorArgs[0] = a1;
case 0:
default:
}
/* eslint-enable no-fallthrough */This replaces the previous three-line unconditional assignment. Only the first argsLength slots are ever read — the arg getters are built to match — so writing the unused tail slots was already harmless. The new form trades 3 lines for 12 plus a pair of lint directives, on a path that runs on every selector call, to save at most two array writes.
Failure scenario: Every future reader of this hot path has to work out that the fallthrough is intentional and that case 0 / default are deliberately empty, to conclude the behavior is identical to the three assignments it replaced.
Fix: Restore the unconditional form and drop both eslint directives:
fn.selectorArgs[0] = a1;
fn.selectorArgs[1] = a2;
fn.selectorArgs[2] = a3;2. 🟡 Two more hand-unrolled arities for cold paths
Location: packages/utils/src/store/createSelector.ts:165
The 7- and 8-function branches add ~24 lines that repeat the existing pattern verbatim. The unrolling earns its place for the common 1–5 selector cases; a selector with 7 inputs is rare by construction, and neither branch is reachable from Base UI itself (createSelector has no callers outside packages/utils/src/store), so the bytes land in MUI X bundles for paths that will almost never run.
Failure scenario: The next arity bump copies the block again, and the cascade grows another ~12 lines per level, each an independent opportunity for an off-by-one in the h(va, vb, vc, vd, ve, vf, vg, a1, a2, a3) argument list.
Fix: Keep the unrolled branches for the hot low arities and collapse 6–7 selectors into one generic tail:
} else if (a && b && c && d && e && f && g) {
const fns = [a, b, c, d, e, f, g, h].filter(Boolean) as Function[];
const combine = fns.pop()!;
selector = (state: any, a1: any, a2: any, a3: any) =>
combine(...fns.map((fn) => fn(state, a1, a2, a3)), a1, a2, a3);
}Verdict
Approve - everything above is a non-blocking test or cleanup nit; the ported behavior itself checks out.
🤖 Review generated with Claude Code
…eaningful - The memoizeOptions merge test passed the same state reference twice, so reselect's argsMemoize short-circuited before the result memoizer ran and the assertion held whether or not Object.is survived the merge. Call the selector with a derived state, which shares the cache key, so the memoize equality actually decides; dev-mode checks are disabled so a regression fails the assertion instead of a console warning. - Add a runtime test for the single-combiner form with extra arguments, the path where the identity selector and the argument getters must combine. It was covered only by a type-level assertion. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Both test findings fixed in b53610d. Test 1 (vacuous Test 2 (single-combiner form with extra arguments) — Added the runtime test. Mutation-checked against the pre-PR behavior ( 113 store tests pass; utils typecheck, lint and prettier clean. On the two simplifications: I'm keeping the fallthrough 🤖 Generated with Claude Code |
Part of the effort to deduplicate the store implementations between
@mui/x-internalsand@base-ui/utils, so MUI X packages can consume the Base UI store instead of shipping a parallel copy.Ports the
@mui/x-internalsselector implementation into@base-ui/utils/store(supersedes the store changes from therefactor-store-mergebranch, rebased onto current master):createSelector: support 7–8 input selectors (previously capped at 6).createSelectorMemoized: rewrap ascreateSelectorMemoizedWithOptions(options)factory exposing reselect memoize overrides (used by x-charts), and fix the single-combiner case to wrap an identity input selector instead of passing the bare combiner to reselect.Store.create(): restore the static factory used by MUI X call sites.Store.test.tscovering selector arity limits, memoization cache-key behavior, and extra-args passing.Kept over the old branch: master's stricter
set/updatetypings from #5423. Error messages are unchanged, so no new error codes.With this released,
@mui/x-internals/storecan become a re-export of@base-ui/utils/store(plus its localuseStoreEffect, which only uses the public store surface).🤖 Generated with Claude Code