Skip to content

test(plugin-form): settle each wizard step before typing (#2982) - #2983

Merged
os-zhuang merged 1 commit into
mainfrom
claude/wizardform-regression-flaky-test-e5b76a
Jul 30, 2026
Merged

test(plugin-form): settle each wizard step before typing (#2982)#2983
os-zhuang merged 1 commit into
mainfrom
claude/wizardform-regression-flaky-test-e5b76a

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#2982.

The flake

WizardForm.regression"submits the merged payload" occasionally saw the create POST carry { name: 'Alice' } only, dropping the last step's note.

The inner step form is reused across steps (one react-hook-form instance), and WizardForm feeds it defaultValues={formData}. When that value changes, the form applies it with reset()in a passive effect — i.e. one commit after the new step's inputs are already in the DOM. waitFor(input) can resolve inside that window, and a value typed there is lost: the pending reset replaces the whole RHF record with the earlier steps' data.

Captured interleaving (instrumented run):

test: waitFor(note) RESOLVED
form: RESET to {"name":"Alice"} (values before: {"name":"Alice","note":"hello"}) ← wipes note
test: change note done
→ payload {"name":"Alice"}

The issue title's diagnosis is slightly off, and it matters: the value is lost before the submit, during the change. Waiting afterfireEvent.change — the fix the title suggests — would not have helped. The step has to be settled before typing.

The fix

A settledStepInput() helper that waits for the step's input and drains the pending effects, so the defaultValues reset cannot land after the change. Applied to both steps (step 1 has the same window against the initial mount reset).

Why this doesn't weaken the guard

Harness-only — nothing asserted changed. Verified by mutation: breaking both value carriers (RHF cross-step retention + the wizard's {...formData, ...stepData} merge) fails the old and the new test identically, with the exact { note: 'hello' } "last-only body" the file's docblock names.

Worth noting the two carriers are redundant on their own — breaking either one alone still passes.

Verification

Scenario replayed 1500× per arm in-process, under 10 competing CPU hogs:

armfailures / 1500
before7 (~0.5%)
after0

Full plugin-form suite: 24 files / 217 tests pass. No new tsc or eslint findings in the changed file.

Follow-up (not in this PR)

The same window exists in the product, narrowly: form.tsx commits new defaultValues one frame before its reset() runs, so input typed in that gap is silently discarded. In a browser it takes a busy main thread plus typing on the very first frame of a step, so it is far less likely than in tests — but it is the same defect, and it lives in a shared renderer every form uses. Deliberately left alone here to keep this change harness-only.

🤖 Generated with Claude Code

`WizardForm.regression` "submits the merged payload" flaked on CI: the create
POST occasionally carried `{ name }` only, dropping the last step's field.
The step form is REUSED across steps (one react-hook-form instance) and the
wizard feeds it `defaultValues={formData}`. A change to that value is applied
with `reset()` in a passive effect — one commit AFTER the new step's inputs are
already in the DOM. `waitFor(input)` can resolve inside that window, and a value
typed there is lost: the pending reset replaces the whole RHF record with the
earlier steps' data. Captured interleaving:
test: waitFor(note) RESOLVED
form: RESET to {"name":"Alice"} (values before: {"name":"Alice","note":"hello"})
test: change note done
→ payload {"name":"Alice"}
Note the loss lands BEFORE the submit, so waiting *after* the change (as the
issue title suggests) would not have helped — the step has to be settled before
typing. `settledStepInput()` drains the pending effects after the input mounts.
Harness-only: nothing asserted changes, and the guarded regression still bites —
breaking both value carriers (RHF retention + the wizard's cross-step merge)
fails old and new test identically with `{ note: 'hello' }`.
Measured under CPU load, 1500 iterations each: before 7 failures, after 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercelBot commented Jul 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectuiIgnoredIgnoredJul 30, 2026 6:57am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)27.9 KB350 KB
Entry fileindex-CDVmrVQB.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)8.20KB2.97KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)7.57KB2.97KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)22.10KB4.37KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.12KB3.41KB
auth (LoginForm.js)17.86KB5.29KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.43KB2.09KB
auth (SocialSignInButtons.js)9.60KB3.89KB
auth (UserMenu.js)3.40KB1.22KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)35.76KB9.11KB
auth (createAuthenticatedFetch.js)4.37KB1.69KB
auth (index.js)2.25KB1.01KB
auth (org-roles.js)6.72KB2.85KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)4.91KB0.87KB
auth (useIsWorkspaceAdmin.js)1.61KB0.85KB
collaboration (CommentThread.js)18.38KB4.49KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)3.65KB1.42KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.25KB0.53KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)450.74KB98.10KB
core (index.js)2.16KB0.78KB
create-plugin (index.js)9.28KB2.98KB
data-objectstack (index.js)134.67KB34.24KB
fields (index.js)221.10KB54.18KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.32KB1.77KB
i18n (index.js)2.46KB0.96KB
i18n (pickLocalized.js)1.70KB0.83KB
i18n (provider.js)5.37KB1.72KB
i18n (useObjectLabel.js)25.17KB5.80KB
i18n (useSafeTranslation.js)3.26KB1.44KB
layout (index.js)38.45KB10.67KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.74KB
mobile (index.js)1.50KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)4.42KB1.27KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.71KB0.42KB
mobile (useResponsiveConfig.js)1.36KB0.63KB
mobile (useSpecGesture.js)1.77KB0.77KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)6.84KB2.42KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)3.67KB1.12KB
permissions (evaluator.js)4.41KB1.44KB
permissions (index.js)0.91KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.52KB
permissions (usePermissions.js)1.55KB0.71KB
plugin-ai (index.js)15.71KB3.79KB
plugin-calendar (index.js)44.90KB12.35KB
plugin-charts (index.js)57.26KB16.24KB
plugin-chatbot (index.js)179.93KB42.67KB
plugin-dashboard (index.js)109.60KB28.33KB
plugin-designer (index.js)210.56KB42.56KB
plugin-detail (index.js)216.52KB53.02KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)103.32KB25.08KB
plugin-gantt (index.js)162.26KB39.53KB
plugin-grid (index.js)179.45KB47.03KB
plugin-kanban (index.js)47.82KB13.18KB
plugin-list (index.js)98.30KB23.23KB
plugin-map (index.js)16.80KB5.24KB
plugin-markdown (index.js)13.65KB4.67KB
plugin-report (index.js)37.77KB10.00KB
plugin-timeline (index.js)25.03KB7.11KB
plugin-tree (index.js)8.36KB2.81KB
plugin-view (index.js)85.47KB20.82KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.55KB0.67KB
providers (UploadProvider.js)11.71KB3.53KB
providers (index.js)0.44KB0.22KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)4.80KB2.03KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)18.70KB6.09KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.02KB0.55KB
sdui-parser (codegen.js)4.09KB1.74KB
sdui-parser (index.js)3.47KB1.54KB
sdui-parser (parse.js)10.04KB2.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)4.69KB1.48KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)0.77KB0.41KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (index.js)1.92KB0.93KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)0.20KB0.18KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.04KB1.93KB
types (system-fields.js)2.39KB1.17KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)0.75KB0.46KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-zhuang
os-zhuang merged commit baa6e63 into mainJul 30, 2026
16 checks passed
@os-zhuang
os-zhuang deleted the claude/wizardform-regression-flaky-test-e5b76a branch July 30, 2026 07:02
os-zhuang added a commit that referenced this pull request Jul 30, 2026
…filled (#2982) (#2991)
The form renderer adopts a changed `defaultValues` with `form.reset()`, which
replaces the WHOLE react-hook-form record — so it also blanks every field the
incoming defaults say nothing about. And it runs in a PASSIVE effect, one commit
after those fields were committed and painted, so input landing in that window
was silently discarded.
PR #2983 fixed only the test that caught it. This is the product-side hole it
left open. The wizard reuses ONE inner form across steps and feeds it
`defaultValues={formData}` — the merge of the steps submitted SO FAR — so at
every step boundary the incoming defaults are missing exactly the fields now on
screen:
RESET to {"name":"Alice"} (values before: {"name":"Alice","note":"hello"})
-> create POST {"name":"Alice"} — the last step is gone
The reset now carries such a value across instead of dropping it. Deliberately
narrow: only a field the CALLER HAS NEVER CARRIED (absent from both the outgoing
and the incoming defaults) and whose value the user actually changed is eligible.
Wherever the caller has an opinion it stays authoritative, so all three
load-bearing paths keep today's behavior exactly:
- an edit-mode record landing after first paint still fills every field it
names — an untouched field is empty-ish against the baseline under the same
comparison the dirty check uses, so a widget normalizing its own empty value
on mount ('' -> null) is not mistaken for input. This is why RHF's
`dirtyFields`/`keepDirtyValues` could not be used: it flags exactly those
self-normalizing fields, and would have rejected the loaded record for them;
- a `recordId` swap still replaces the record outright — drawer/modal/split
forms re-fetch WITHOUT re-entering their loading branch, so record B lands in
the still-mounted form and must not inherit an abandoned edit to record A;
- a field the caller withdraws from its defaults stops being the user's.
A reset that carried input also now reports dirty (it is, against the caller's
defaults) rather than unconditionally announcing pristine, so a host's discard
guard keeps hearing the truth.
Measured, pre-#2983 harness under CPU load, 400 iterations per arm: before 5
failures (each `{"name":"Alice"}`, note lost), after 0. New renderer-level test
pins the fix and the three paths above deterministically, without timing games.
Verified: `npx vitest run packages/plugin-form/ packages/components/` — 68 files
/ 577 tests pass; `@object-ui/components` type-check and lint clean.
Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
os-zhuang added a commit that referenced this pull request Jul 30, 2026
…ers them (#3001)
The form applies a `defaultValues` change by resetting react-hook-form to it.
While that ran in a PASSIVE effect there was a window: the render had already
committed, so the new inputs were mounted and interactive, but the form still
held the old record. Anything typed in that window was destroyed — the pending
`reset()` overwrote the whole record with `defaultValues`, dropping the field
the user had just filled, with no error and nothing in the payload.
It surfaced as the flaky wizard test fixed in #2983: a step transition changes
`defaultValues`, and `note` typed on the new step vanished from the create
body. That fix drained pending effects in the test, which AVOIDS the window;
this one CLOSES it. Measured with the pre-fix test pattern, 1500 replays under
CPU load: 6 failures before, 0 after.
Running the reset as a layout effect is only half of it. The `form_change`
subscription was silent across a reset by accident of ordering, not by design:
`onAction` is usually an inline arrow, so its identity changes every render and
the subscription effect re-runs each commit — and React runs every passive
DESTROY before any passive CREATE, so the watcher was unsubscribed before the
reset fired. Hoisting only the reset to the layout phase puts it AHEAD of that
cleanup, so the still-live previous subscription sees it and a record landing
looks like the user having edited every field it filled. That regressed #2968
deterministically (`changes=[{"category":"not-offered","status":"pending"}]`).
Both `form.watch` subscriptions therefore move to the same phase, restoring the
destroy-then-create order exactly.
The new test pins the window shut without depending on timing: React flushes
passive effects in tree order, so a probe sibling rendered before the form is
guaranteed to run inside the window and types from there. It fails on every run
against the previous code.
Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

1 participant

@os-zhuang