Skip to content

MobX crop store for Image Cropper - #2321

Merged
rahmanunver merged 5 commits into
feat/image-cropper-polish-rotate-bwfrom
experiment/image-cropper-mobx-store
Jul 14, 2026
Merged

MobX crop store for Image Cropper#2321
rahmanunver merged 5 commits into
feat/image-cropper-polish-rotate-bwfrom
experiment/image-cropper-mobx-store

Conversation

@rahmanunver

@rahmanunver rahmanunver commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Pull request type

Refactoring (e.g. file rename, variable rename, etc.)


Description

Stacked on top of the Image Cropper PR (feat/image-cropper-polish-rotate-bw) — base is set to that branch, so this diff shows only the MobX refactor, not the base PR's changes.

Why. Review feedback flagged that the widget's state had become too coupled to React. ImageCropperContainer carried five state-mirroring refs (committedCropRef, zoomRef, grayscaleRef, liveCropRef, zoomAnchorRef) plus two callback mirrors (markInternalRef, showPreviewRef). They existed only because applyCrop was a stable-identity useCallback (kept stable for the 400ms auto-apply debouncer) and therefore couldn't read current state through its stale closure. A MobX store method reads this.zoom directly, so the refs are no longer needed.

Moved into ImageCropperStore (MobX): the crop-domain working state — liveCrop, committedCrop, zoomAnchor (observable.ref), zoom, grayscale (observable) — plus aspect (computed) and the crop operations as actions (setZoom, commitCrop, toggleGrayscale, rotate, reset, initFromImageLoad, onImageChanged). The store also owns the debounce + auto-apply gate (absorbing useAutoApplyCrop). Crop ops are now plain calls like store.setZoom(v) / store.rotate(90).

Stayed normal React state (imperative / DOM-bound): the imageRef DOM node, usePreviewSrc (blob object-URL lifecycle), useOriginalImage (fetch + unmount cleanup + internal-change flag), and CropArea's local DOM-measurement state. These are injected into the store via setDeps. Config props are read live through the mobx-kit gate, not copied into observables.

Controlled/uncontrolled unchanged. The only externally controlled value is props.image (Mendix EditableValue); zoom/rotation/grayscale/crop are internal. Inbound image changes sync through one uri-keyed effect calling store.onImageChanged(); outbound writes go markInternalChange()setValue(), with the internal-change flag absorbing the resulting uri change so there's no feedback loop. No reactions used for prop syncing.

Preview stays an async action, not a computedcropImage/rotateImage are async canvas operations reading a live DOM <img>, so they can't be a pure synchronous computed.

Removed: the 5 mirror refs + 2 callback mirrors, and the useAutoApplyCrop / useImageCropperState hooks (subsumed by the store). ImageCropperContainer is now an observer wired via the shared widget-plugin-mobx-kit gate pattern (GateProvider + useConst + useSetup), matching custom-chart-web.

Public API unchanged — no XML/props changes; widget behavior is identical.

@rahmanunver
rahmanunver requested a review from a team as a code owner July 13, 2026 10:16
@github-actions

This comment has been minimized.

@rahmanunver rahmanunver changed the title Experiment: MobX crop store for Image Cropper MobX crop store for Image Cropper Jul 13, 2026
…bx-kit

Dependencies for the MobX crop store: mobx 6.12.3 and mobx-react-lite
4.0.7 (pinned to match the rest of the monorepo) plus the shared
widget-plugin-mobx-kit gate/setup utilities.
Centralize crop-domain working state (live/committed crop, zoom,
grayscale, zoom anchor) and its operations in a MobX store. Store
methods read observable state directly, so the debounced auto-apply no
longer needs a stable-identity callback reading state-mirroring refs.

The store owns the debounce + armed/user-interacted apply gate
(previously useAutoApplyCrop) and exposes crop operations as actions:
setZoom, commitCrop, toggleGrayscale, rotate, reset, initFromImageLoad,
onImageChanged. aspect is a computed derived from gated props.

Imperative React-owned dependencies (live img element, blob-URL
preview, original-image capture, internal-change flag) are injected via
setDeps and not owned by the store. Preview generation stays an async
action, not a computed, because cropImage/rotateImage are async canvas
operations with side effects.

Add headless unit tests covering the commit gate, debounce coalescing,
no-stale-reads, rotate/reset/grayscale flows, inbound image sync, and
read-only/no-crop guards.
Wire ImageCropperContainer to the MobX store via the mobx-kit gate
pattern (GateProvider + useConst + useSetup) and wrap it in observer.
Crop operations become plain store method calls.

Remove the five state-mirroring refs (committedCropRef, zoomRef,
grayscaleRef, liveCropRef, zoomAnchorRef) and the two callback mirrors
(markInternalRef, showPreviewRef); the store reads its own observable
state, so stable-identity closures no longer need them. Config and the
controlled image value are read live through the gate; the bound uri is
synced inbound via a single effect calling store.onImageChanged.

usePreviewSrc, useOriginalImage and the DOM image ref stay in React and
are injected into the store through setDeps. Delete useAutoApplyCrop and
useImageCropperState, now subsumed by the store.
@rahmanunver
rahmanunver force-pushed the experiment/image-cropper-mobx-store branch from fb16fae to edfa936 Compare July 13, 2026 10:39
@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

Move the SetStateAction resolution out of the container and into
ImageCropperStore.setZoom, which now accepts a value or updater and
resolves the functional form against its own zoom. The container drops
to setZoom={next => store.setZoom(next)}, making the store the single
owner of current zoom. The functional-updater contract stays on the
CropArea/ZoomContainer props (the wheel-zoom hook drives setZoom(prev
=> …) and the editor preview passes a no-op). Behavior unchanged.

Also sync the image-cropper openspec: the "No image available"
scenario now reflects the editable noImageCaption empty-state text.
@github-actions

Copy link
Copy Markdown
Contributor

AI Code Review

⚠️ Approved with suggestions — low-severity items only, safe to merge


What was reviewed

File Change
packages/pluggableWidgets/image-cropper-web/src/stores/ImageCropperStore.ts New MobX store — crop-domain state, debounce gate, async ops, original capture, preview blob lifecycle
packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx Gutted from 254 lines to 37 — now an observer wired via GateProvider + useConst + useSetup
packages/pluggableWidgets/image-cropper-web/src/components/CropArea.tsx Import reorder only
packages/pluggableWidgets/image-cropper-web/src/stores/__tests__/ImageCropperStore.spec.ts New store spec (462 lines) replacing the three deleted hook specs
packages/pluggableWidgets/image-cropper-web/src/hooks/useAutoApplyCrop.ts Deleted — absorbed into store
packages/pluggableWidgets/image-cropper-web/src/hooks/useImageCropperState.ts Deleted — absorbed into store
packages/pluggableWidgets/image-cropper-web/src/hooks/useOriginalImage.ts Deleted — absorbed into store
packages/pluggableWidgets/image-cropper-web/src/hooks/usePreviewSrc.ts Deleted — absorbed into store
packages/pluggableWidgets/image-cropper-web/src/hooks/__tests__/useImageCropperState.spec.ts Deleted (hook deleted)
packages/pluggableWidgets/image-cropper-web/src/hooks/__tests__/useOriginalImage.spec.ts Deleted (hook deleted)
packages/pluggableWidgets/image-cropper-web/src/hooks/__tests__/usePreviewSrc.spec.ts Deleted (hook deleted)
packages/pluggableWidgets/image-cropper-web/package.json Added mobx, mobx-react-lite, @mendix/widget-plugin-mobx-kit deps
packages/pluggableWidgets/image-cropper-web/openspec/specs/image-cropper/spec.md Spec text update (no-image caption)
pnpm-lock.yaml Lockfile update — out of scope

Skipped (out of scope): dist/, pnpm-lock.yaml (except to confirm dep resolution)

CI status could not be verified automatically — run gh pr checks 2321 manually before merging.


Findings

⚠️ Low — Store tests use manual mock for EditableValue instead of EditableValueBuilder

File: packages/pluggableWidgets/image-cropper-web/src/stores/__tests__/ImageCropperStore.spec.ts line 1262
Problem: makeImageProp hand-rolls the EditableValue<WebImage> shape with as unknown as ImageProp casting. The repo convention (documented in the skill and used across other widget specs) is to use EditableValueBuilder from @mendix/widget-plugin-test-utils. The manual mock covers the Available + readOnly guard cases but doesn't exercise the Loading or Unavailable branches of the store's guards in applyCrop, rotate, and reset — where all three guard image.status !== ValueStatus.Available.
Fix:

import { EditableValueBuilder } from "@mendix/widget-plugin-test-utils";

// Available with a value
const image = new EditableValueBuilder<WebImage>()
    .withValue({ uri: "http://localhost/img.png", name: "img.png" } as WebImage)
    .build();

// Read-only
const readOnlyImage = new EditableValueBuilder<WebImage>()
    .withValue({ uri: "http://localhost/img.png", name: "img.png" } as WebImage)
    .isReadOnly()
    .build();

// Loading (verify guards in applyCrop/rotate/reset skip the canvas op)
const loadingImage = new EditableValueBuilder<WebImage>().isLoading().build();

Adding a Loading-state guard test would make the coverage complete.


Positives

  • Correct makeObservable discipline: explicit annotations, not makeAutoObservable; private apply-gate methods (armed, applyCrop, applyNow, applyDebounced, revokePreview) are correctly annotated false because they touch only plain (non-observable) fields and thus don't need action batching.
  • Proper runInAction after every await: both rotate (wraps the liveCrop/committedCrop reset) and captureOriginal (wraps canRestore) correctly batch their post-await observable mutations. applyCrop has no post-await observable writes so no runInAction needed there — correctly omitted.
  • Generation-token race guard in captureOriginal: the fetchGeneration increment correctly drops superseded in-flight fetches, mirroring the cancelled flag from the old useOriginalImage hook. The new test "drops a superseded in-flight fetch" verifies this path.
  • Internal-change feedback-loop prevention preserved: markInternalChange() + internalChange flag in onUriChanged correctly absorbs own-baked URI changes, so setValue → uri-change → onUriChanged doesn't trigger a re-fetch or crop-clear. The "adopts our own bake's uri without refetching" test covers this.
  • Comprehensive spec: 462 lines, describe/it structure with a makeStore factory, flush() helper for async, fake timers, and afterEach cleanup — matches repo test conventions. Coverage spans initial state, aspect computed, commitCrop gate, zoom debouncing, no-stale-reads proof, rotate, reset, inbound sync, guards, original capture, and preview blob lifecycle.
  • Ref-free container: removing all five state-mirroring refs and two callback-mirror refs from the container is exactly the right outcome — the PR description nails why they existed and why the store makes them unnecessary.

@rahmanunver
rahmanunver merged commit 067fecc into feat/image-cropper-polish-rotate-bw Jul 14, 2026
3 of 5 checks passed
@rahmanunver
rahmanunver deleted the experiment/image-cropper-mobx-store branch July 14, 2026 13:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants