diff --git a/docs/adoption/hunts/README.md b/docs/adoption/hunts/README.md index 12145187..7e6638d3 100644 --- a/docs/adoption/hunts/README.md +++ b/docs/adoption/hunts/README.md @@ -7,6 +7,7 @@ the project's maintainers to take or leave. One report per target; nothing here | target | report | patch and draft text | harness | |---|---|---|---| | Strapi admin panel (5.59.0) | [strapi.md](strapi.md) | [strapi.patch](strapi.patch), [strapi-issue.md](strapi-issue.md) | [strapi/](strapi/) | +| Vendure dashboard (3.8.1, refreshed 2026-11-03 at `f97b187`) | [vendure.md](vendure.md) | [vendure.patch](vendure.patch), [vendure-issue.md](vendure-issue.md) | [vendure-hunt.mjs](vendure-hunt.mjs), [vendure-refresh/](vendure-refresh/) | Every report gives the versions and commits, the exact commands, every scenario with its verdict including the negatives, the finding with its evidence in causal order, the root cause, the fix, the test result, the check on the diff --git a/docs/adoption/hunts/vendure-issue.md b/docs/adoption/hunts/vendure-issue.md index 49ce43ff..14f4c7e6 100644 --- a/docs/adoption/hunts/vendure-issue.md +++ b/docs/adoption/hunts/vendure-issue.md @@ -1,13 +1,61 @@ # Vendure: draft issue and draft pull request Drafts for the owner to send. Nothing here has been posted to vendurehq/vendure. Evidence and method: -[`vendure.md`](./vendure.md). The patch: [`vendure.patch`](./vendure.patch) (against `master` at `aba3e9c`). - -Before filing: check that nobody has filed the same dashboard bug since 2026-10-24, and decide whether to file a new -issue (below) or to comment on [vendurehq/vendure#2117](https://github.com/vendurehq/vendure/issues/2117). #2117 is the -legacy admin-ui report of the same bug; its last maintainer comment (2025-04-02) says the fix "will be included in a -future release of the new `@vendure/dashboard`". The issue below links it. If the new issue gets a number, the -`#2117` references in the patch's comments and test title can be changed to it. +[`vendure.md`](./vendure.md) (refreshed 2026-11-03, section 9). The patch: [`vendure.patch`](./vendure.patch) (against +`master` at `f97b187`, 2026-11-02; it also applies unchanged to the hunt's `aba3e9c`). + +## New issue or a comment on #2117: file a new issue + +Checked 2026-11-03. [vendurehq/vendure#2117](https://github.com/vendurehq/vendure/issues/2117) is still open, labelled +`@vendure/admin-ui`, `P4: low`, `type: bug`, `contributions welcome`, assigned to michaelbromley, with no linked branch +or pull request. Its last comment is still dlhck's of 2025-04-02: "Will be included in a future release of the new +`@vendure/dashboard`. Happy for community contributions to the legacy `@vendure/admin-ui`". The only later activity is +a label added and removed on 2026-07-17, with no comment. + +File a **new issue** for the dashboard, and do not comment on #2117: + +- Vendure's CONTRIBUTING asks for a bug fix to start with "an issue detailing the bug" in which you "indicate that you + intend to fix it", and its `AGENTS.md` asks for `Fixes #ISSUE_NUMBER` (or `Relates to #...`) in the commit body. The + patch fixes the React dashboard only. `Fixes #2117` would close the admin-ui report, which still reproduces (report + section 6). A dashboard issue gets the dashboard label and a number the PR can close. +- [vendurehq/vendure#4884](https://github.com/vendurehq/vendure/issues/4884) (closed 2026-09-30 by + [#5007](https://github.com/vendurehq/vendure/pull/5007)) says "the React dashboard does avoid the double-click". On + 3.8.1 it does not. A new issue puts that on record with the steps. A comment on a two-year-old admin-ui issue would not. +- Mentioning #2117 and #4884 in the new issue adds the cross-reference to both threads, so no comment is needed. + +## Before filing (what the 2026-11-03 refresh did, and what is left) + +Done by the refresh session (report section 9): + +- [x] Searched the tracker for anything filed on the same bug since 2026-10-24. Nothing on the dashboard's double submit + was found. The search covered issues and pull requests for "double click", "double-click", "double submit", + "twice dashboard create", "duplicate dashboard create", "isPending createMutation", "submit twice" and + "useDetailPage isPending". The related #4884 and #5007 are linked below. +- [x] Read the current CONTRIBUTING.md, the issue and PR templates, `.github/ISSUE_TEMPLATE/config.yml` (blank issues + off) and the repository's `AGENTS.md`. Neither CONTRIBUTING nor AGENTS.md has an AI policy. Many recent `master` + commits carry a `Co-authored-by: Claude` trailer (36 of the 270 commits since 2026-10-01). Contributions need the + CLA, which a bot asks you to sign when the PR opens. There is no DCO. +- [x] Rebased the patch on `master` `f97b187` (it applied unchanged). Following Vendure's `AGENTS.md`, the e2e test + moved from a new `e2e/tests/regression/` file into the existing `e2e/tests/catalog/collections.spec.ts`: + "`tests/regression/` — **only** for tests that genuinely don't fit any existing suite". +- [x] Ran the unit tests and the e2e tests three times each on the unfixed code and three times each on the fixed code, + and re-ran the reproduction without GenClass on both. Ran `check-types`, the dashboard unit suite, and eslint and + prettier on the changed files. +- [x] Rewrote the version, commit, environment and numbers in the drafts below. Line numbers are unchanged since + `aba3e9c`. + +Left for the sender, by hand: + +- [ ] Search the tracker once more on the day you file (the queries above), in case something was filed after + 2026-11-03. +- [ ] Reproduce by hand: run the dev server, double-click **Create** on a new collection, and see two collections and + two `CreateCollection` requests (steps in the issue below). +- [ ] Apply the patch to a fresh fork at current `master`. Replace `#2117` with the new issue's number in the four + files (`sed -i 's/#2117/#NNNN/g'` on the paths in the patch). Run the tests yourself: + `npx vitest run src/lib/framework/form-engine/use-generated-form.spec.tsx`, then the e2e command in the PR + text. +- [ ] Read the whole diff (240 added and 9 removed lines in four files). +- [ ] File the issue, then open the PR against `master` from your fork. Sign the CLA when the bot asks. --- @@ -19,7 +67,10 @@ future release of the new `@vendure/dashboard`". The issue below links it. If th On every detail page built with `useDetailPage` (collections, products, facets, customers and the rest), a quick double-click on the **Create** button sends the create mutation twice and creates two entities. A double-click on -**Update** sends the update mutation twice. This is the dashboard counterpart of #2117 (legacy admin-ui). +**Update** sends the update mutation twice. This is the dashboard counterpart of #2117 (legacy admin-ui). It is also +not covered by #5007: that fix stops concurrent updates from writing duplicate translation rows (#4884), but a double +Create still makes two separate entities. #4884 says "the React dashboard does avoid the double-click", and on 3.8.1 +that is not the case. Two things combine: @@ -41,8 +92,8 @@ Two things combine: 4. Open the collection list: there are two collections with that name (two slugs, `name` and `name-2`), and the network tab shows two `CreateCollection` requests. -It also reproduces with Playwright's plain `locator.dblclick()`: the regression test in the linked PR fails on -`master` in 5 of 5 runs for Create and 5 of 5 for Update (two mutations each time). +It also reproduces with Playwright's plain `locator.dblclick()`. The regression test in the linked PR fails on +`master` in 3 of 3 runs for Create and 3 of 3 for Update, with two mutations each time. **Expected behavior** @@ -50,19 +101,25 @@ One create (or update) mutation per double-click, and one entity. **Actual behavior** -Two mutations; two entities on Create. Measured on `master` (`aba3e9c`, 3.8.1) with the SQLite dev server and the -production build of the dashboard, presses sent through CDP 0 to 150 ms apart: 77 of 160 Create trials sent `create*` twice (all 40 at a 0 ms gap, 33 of 40 at 30 ms, 4 of 40 at 80 ms) -and left 60 duplicate collections, products and facets in the database; 23 of 48 Update trials sent the update -twice. With 100 ms of added request latency (a remote server), 37 of 48 Create trials doubled, including every page at -an 80 ms gap. The duplicates get de-duplicated slugs or codes (`name`, `name-2`), so nothing fails visibly. +The mutation is sent twice, and Create makes two entities. Measured on `master` (`f97b187`, 3.8.1) with the SQLite +dev server and the production build of the dashboard. Each trial sent two presses through Chrome DevTools Protocol, +0, 30, 80 or 150 ms apart, 3 trials per page and gap: + +- **Create** (Collection, Product, Facet, Customer): 21 of 48 trials sent `create*` twice. That was 12 of 12 at a 0 ms + gap, 9 of 12 at 30 ms, and none at 80 or 150 ms. The trials left 15 duplicate collections, products and facets in the + database. +- **Update**: 25 of 48 trials sent the update twice. That was 12 of 12 at 0 ms, 9 of 12 at 30 ms, and 4 of 12 at + 80 ms. -On Customer the server rejects the second create (the email address is taken), so only the request is duplicated -there. +The duplicates get de-duplicated slugs or codes (`name`, `name-2`), so nothing fails visibly. On Customer the server +rejects the second create (the email address is taken), so only the request is duplicated there. The window is about +one request long, so it grows with latency. With 100 ms added to every request, a measurement on 2026-10-24 found +doubled creates on every page at an 80 ms gap. **Environment** -- @vendure/core version: 3.8.1 (`master` at `aba3e9c`, 2026-10-23) -- Nodejs version: 22.22.0 +- @vendure/core version: 3.8.1 (`master` at `f97b187`, 2026-11-02) +- Nodejs version: 22.23.4 - Database: SQLite (dev server, `DB=sqlite`) - Operating System: Linux - Browser: Chromium 153 @@ -74,13 +131,16 @@ Click once and wait for the toast. **Additional context** -I intend to fix this and have a patch ready: `createMutation.isPending` added to `isPending`, and a guard in -`useGeneratedForm`'s submit handler that ignores a submit while the previous one is being validated or while the -promise returned by `onSubmit` is pending (`useDetailPage` returns its `mutateAsync` promise). It adds no dependency -and does not change the public API apart from `GeneratedFormOptions.onSubmit` also accepting a returned promise. Unit -tests in `use-generated-form.spec.tsx` and a Playwright regression test in `e2e/tests/regression/` are included. I -found it with GenClass, a browser library that reports duplicate submits; the steps above need nothing but the -dashboard. +I intend to fix this and have a patch ready. It adds `createMutation.isPending` to `isPending`. It also adds a guard in +`useGeneratedForm`'s submit handler that ignores a submit while the previous one is being validated, or while the +promise returned by `onSubmit` is pending (`useDetailPage` returns its `mutateAsync` promise). The patch adds no +dependency. The only API change is that `GeneratedFormOptions.onSubmit` may also return a promise. It includes unit +tests in `use-generated-form.spec.tsx` and a Playwright test in `e2e/tests/catalog/collections.spec.ts`. Related: +#2117 (the same bug in the legacy admin-ui, which this does not touch), #4884 and #5007. + +**AI assistance:** I used Claude (Claude Code) to investigate this, write the fix and the tests, and draft this +description; I reviewed the change and ran the tests and the manual check myself. The race was found with GenClass, a +tool I work on, which replays the same steps with the server's responses delivered in different orders. --- @@ -88,38 +148,52 @@ dashboard. **Base:** `master`. **Title (conventional, lower-case subject):** `fix(dashboard): submit a detail form only once on double-click` +**Commit message** (one commit, body per Vendure's `AGENTS.md`): + +``` +fix(dashboard): submit a detail form only once on double-click + +Fixes #NNNN. Relates to #2117. +``` + +(Many Vendure commits also carry a `Co-authored-by:` trailer for Claude. Adding one is optional and fits the +disclosure below.) + # Description -Fixes #ISSUE (dashboard counterpart of #2117). +Fixes #NNNN. Relates to #2117 (the same bug in the legacy admin-ui, not touched here). -A double-click on Create on any `useDetailPage` page created the entity twice, and a double-click on Update sent the +A double-click on Create on any `useDetailPage` page created the entity twice. A double-click on Update sent the update twice: - `useDetailPage` left `createMutation.isPending` out of the `isPending` it returns, so the Create button stayed enabled while the create mutation was in flight. - Even with `isPending` wired in, the submit handler in `useGeneratedForm` awaits validation before it calls `onSubmit`, and the button is only disabled on the next render, so a fast second click still submitted again. Adding - `createMutation.isPending` alone does not fix it: with only that change the new e2e test still fails 10 of 10 runs. + `createMutation.isPending` alone does not fix it. With only that change, the new e2e test still failed 10 of 10 + runs in an earlier measurement (2026-10-24). Changes: - `useGeneratedForm`: the submit handler ignores a submit while the previous one is being validated, and while the - promise returned by `onSubmit` is pending. `GeneratedFormOptions.onSubmit` may now return a promise (returning - nothing keeps the old behavior apart from the validation-time guard). -- `useDetailPage`: `onSubmit` returns the create or update `mutateAsync` promise (errors still go to the mutation's - `onError`, the rejection is caught), and `isPending` includes `createMutation.isPending`, so the Create button is - disabled while the request is in flight, like Update. + promise returned by `onSubmit` is pending. `GeneratedFormOptions.onSubmit` may now return a promise. If it returns + nothing, the only change is the guard during validation. +- `useDetailPage`: `onSubmit` returns the create or update `mutateAsync` promise. Errors still go to the mutation's + `onError`, and the rejection is caught. `isPending` now includes `createMutation.isPending`, so the Create button is + disabled while the request is in flight, as the Update button is. Tests: -- `use-generated-form.spec.tsx`: a second submit during validation is ignored; further submits are ignored until the - `onSubmit` promise settles; a submit that fails validation releases the guard. The first two fail on `master`. -- `e2e/tests/regression/double-submit-on-save.spec.ts`: `dblclick()` on Create creates one collection (checked on the - wire and through the Admin API); `dblclick()` on Update sends one update. On `master` both fail 5 of 5 runs; with - the fix both pass 5 of 5. +- `use-generated-form.spec.tsx` covers three cases: a second submit during validation is ignored; further submits + are ignored until the `onSubmit` promise settles; a submit that fails validation releases the guard. The first two + fail on `master`. +- `e2e/tests/catalog/collections.spec.ts`, `Issue #NNNN: Double-click on Create or Update submits once`, has two + tests. `dblclick()` on Create must create one collection, checked on the wire and through the Admin API. + `dblclick()` on Update must send one update. On `master` both failed 3 of 3 runs. With the fix both passed 3 of 3. -Checked locally: `npm run check-types`, `eslint` and `prettier` on the changed files, the dashboard unit suite (961 -passed, 4 skipped), and the new e2e spec. +Checked locally on `master` `f97b187`: `npm run check-types`, eslint and prettier on the changed files, the dashboard +unit suite (972 passed, 4 skipped) and the new e2e tests: +`CI=true VITE_TEST_PORT=5176 npx playwright test --config e2e/playwright.config.ts e2e/tests/catalog/collections.spec.ts --reporter=list`. # Breaking changes @@ -139,3 +213,7 @@ Not applicable. 👍 Most of the time: - [x] I have added or updated test cases - [ ] I have updated the README if needed (not needed) + +**AI assistance:** I used Claude (Claude Code) to investigate this, write the fix and the tests, and draft this +description; I reviewed the change and ran the tests and the manual check myself. The race was found with GenClass, a +tool I work on, which replays the same steps with the server's responses delivered in different orders. diff --git a/docs/adoption/hunts/vendure-refresh/summarize.mjs b/docs/adoption/hunts/vendure-refresh/summarize.mjs new file mode 100644 index 00000000..2a37ad0b --- /dev/null +++ b/docs/adoption/hunts/vendure-refresh/summarize.mjs @@ -0,0 +1,44 @@ +// Summarises the raw run files of docs/adoption/hunts/vendure-hunt.mjs (its OUT=... JSON) into the small table the +// 2026-11-03 refresh of docs/adoption/hunts/vendure.md commits as vendure-refresh/summary.json. Not part of CI. +// +// node docs/adoption/hunts/vendure-refresh/summarize.mjs master-nogc.json patch-nogc.json master-gc.json > summary.json +// +// Per run: trials, failed trials, trials that sent the mutation twice, duplicate entities in the database (Create), +// error responses, and, when GenClass was injected, trials with a duplicate-request witness on the mutation; per +// action and gap the same counts, so the report's tables can be checked against the raw rows. + +import { readFileSync } from "node:fs"; +import { basename } from "node:path"; + +const out = {}; +for (const file of process.argv.slice(2)) { + const run = JSON.parse(readFileSync(file, "utf8")); + const rows = run.rows.filter((r) => !r.failed); + const count = (rs, f) => rs.filter(f).length; + const cell = (rs) => ({ + trials: rs.length, + twoMutations: count(rs, (r) => r.wire >= 2), + duplicateEntities: rs.some((r) => r.action === "create") ? rs.reduce((a, r) => a + Math.max(0, (r.created ?? 0) - 1), 0) : null, + trialsWithTwoEntities: rs.some((r) => r.action === "create") ? count(rs, (r) => r.created >= 2) : null, + genclassDuplicate: run.genclass ? count(rs, (r) => r.genclass > 0) : null, + genclassFalse: run.genclass ? count(rs, (r) => r.genclass > 0 && r.wire < 2) : null, + }); + const byActionGap = {}; + for (const r of rows) ((byActionGap[`${r.action} ${r.gap}`] ??= []).push(r)); + const byPage = {}; + for (const r of rows) ((byPage[`${r.action} ${r.entity}`] ??= []).push(r)); + out[basename(file, ".json")] = { + label: run.label, + base: run.base, + latencyMs: run.latency, + genclass: run.genclass, + failed: run.rows.length - rows.length, + errors: [...new Set(rows.flatMap((r) => r.errors ?? []))], + total: Object.fromEntries( + [...new Set(rows.map((r) => r.action))].map((a) => [a, cell(rows.filter((r) => r.action === a))]), + ), + byActionGap: Object.fromEntries(Object.entries(byActionGap).map(([k, rs]) => [k, cell(rs)])), + byPage: Object.fromEntries(Object.entries(byPage).map(([k, rs]) => [k, cell(rs)])), + }; +} +console.log(JSON.stringify(out, null, 2)); diff --git a/docs/adoption/hunts/vendure-refresh/summary.json b/docs/adoption/hunts/vendure-refresh/summary.json new file mode 100644 index 00000000..0a10edd3 --- /dev/null +++ b/docs/adoption/hunts/vendure-refresh/summary.json @@ -0,0 +1,529 @@ +{ + "date": "2026-11-03", + "vendure": { + "repo": "vendurehq/vendure", + "commit": "f97b18759178bd22cbd88062edcff6c308652342", + "version": "3.8.1" + }, + "genclass": { + "commit": "c1c9e14", + "runtime": "packages/runtime/dist (script tag + agent link)", + "mcp": "@genclass/mcp 0.2.1 in-process" + }, + "browser": "Chromium 153.0.8010.12 headless", + "node": "22.23.4", + "bun": "1.4.4", + "method": "docs/adoption/hunts/vendure-hunt.mjs, TRIALS=3, GAPS=0,30,80,150, ENTITIES=collection,product,facet,customer, LATENCY=0, production builds of the dashboard (vite build + vite preview) against the SQLite dev server", + "sweeps": { + "master-nogc": { + "label": "refresh-master-nogc", + "base": "http://localhost:5175", + "latencyMs": 0, + "genclass": false, + "failed": 0, + "errors": [ + "The email address is not available." + ], + "total": { + "create": { + "trials": 48, + "twoMutations": 21, + "duplicateEntities": 15, + "trialsWithTwoEntities": 15, + "genclassDuplicate": null, + "genclassFalse": null + }, + "update": { + "trials": 48, + "twoMutations": 25, + "duplicateEntities": null, + "trialsWithTwoEntities": null, + "genclassDuplicate": null, + "genclassFalse": null + } + }, + "byActionGap": { + "create 0": { + "trials": 12, + "twoMutations": 12, + "duplicateEntities": 9, + "trialsWithTwoEntities": 9, + "genclassDuplicate": null, + "genclassFalse": null + }, + "create 30": { + "trials": 12, + "twoMutations": 9, + "duplicateEntities": 6, + "trialsWithTwoEntities": 6, + "genclassDuplicate": null, + "genclassFalse": null + }, + "create 80": { + "trials": 12, + "twoMutations": 0, + "duplicateEntities": 0, + "trialsWithTwoEntities": 0, + "genclassDuplicate": null, + "genclassFalse": null + }, + "create 150": { + "trials": 12, + "twoMutations": 0, + "duplicateEntities": 0, + "trialsWithTwoEntities": 0, + "genclassDuplicate": null, + "genclassFalse": null + }, + "update 0": { + "trials": 12, + "twoMutations": 12, + "duplicateEntities": null, + "trialsWithTwoEntities": null, + "genclassDuplicate": null, + "genclassFalse": null + }, + "update 30": { + "trials": 12, + "twoMutations": 9, + "duplicateEntities": null, + "trialsWithTwoEntities": null, + "genclassDuplicate": null, + "genclassFalse": null + }, + "update 80": { + "trials": 12, + "twoMutations": 4, + "duplicateEntities": null, + "trialsWithTwoEntities": null, + "genclassDuplicate": null, + "genclassFalse": null + }, + "update 150": { + "trials": 12, + "twoMutations": 0, + "duplicateEntities": null, + "trialsWithTwoEntities": null, + "genclassDuplicate": null, + "genclassFalse": null + } + }, + "byPage": { + "create collection": { + "trials": 12, + "twoMutations": 6, + "duplicateEntities": 6, + "trialsWithTwoEntities": 6, + "genclassDuplicate": null, + "genclassFalse": null + }, + "create product": { + "trials": 12, + "twoMutations": 6, + "duplicateEntities": 6, + "trialsWithTwoEntities": 6, + "genclassDuplicate": null, + "genclassFalse": null + }, + "create facet": { + "trials": 12, + "twoMutations": 3, + "duplicateEntities": 3, + "trialsWithTwoEntities": 3, + "genclassDuplicate": null, + "genclassFalse": null + }, + "create customer": { + "trials": 12, + "twoMutations": 6, + "duplicateEntities": 0, + "trialsWithTwoEntities": 0, + "genclassDuplicate": null, + "genclassFalse": null + }, + "update collection": { + "trials": 12, + "twoMutations": 9, + "duplicateEntities": null, + "trialsWithTwoEntities": null, + "genclassDuplicate": null, + "genclassFalse": null + }, + "update product": { + "trials": 12, + "twoMutations": 7, + "duplicateEntities": null, + "trialsWithTwoEntities": null, + "genclassDuplicate": null, + "genclassFalse": null + }, + "update facet": { + "trials": 12, + "twoMutations": 6, + "duplicateEntities": null, + "trialsWithTwoEntities": null, + "genclassDuplicate": null, + "genclassFalse": null + }, + "update customer": { + "trials": 12, + "twoMutations": 3, + "duplicateEntities": null, + "trialsWithTwoEntities": null, + "genclassDuplicate": null, + "genclassFalse": null + } + } + }, + "patch-nogc": { + "label": "refresh-patch-nogc", + "base": "http://localhost:5177", + "latencyMs": 0, + "genclass": false, + "failed": 0, + "errors": [], + "total": { + "create": { + "trials": 48, + "twoMutations": 0, + "duplicateEntities": 0, + "trialsWithTwoEntities": 0, + "genclassDuplicate": null, + "genclassFalse": null + }, + "update": { + "trials": 48, + "twoMutations": 0, + "duplicateEntities": null, + "trialsWithTwoEntities": null, + "genclassDuplicate": null, + "genclassFalse": null + } + }, + "byActionGap": { + "create 0": { + "trials": 12, + "twoMutations": 0, + "duplicateEntities": 0, + "trialsWithTwoEntities": 0, + "genclassDuplicate": null, + "genclassFalse": null + }, + "create 30": { + "trials": 12, + "twoMutations": 0, + "duplicateEntities": 0, + "trialsWithTwoEntities": 0, + "genclassDuplicate": null, + "genclassFalse": null + }, + "create 80": { + "trials": 12, + "twoMutations": 0, + "duplicateEntities": 0, + "trialsWithTwoEntities": 0, + "genclassDuplicate": null, + "genclassFalse": null + }, + "create 150": { + "trials": 12, + "twoMutations": 0, + "duplicateEntities": 0, + "trialsWithTwoEntities": 0, + "genclassDuplicate": null, + "genclassFalse": null + }, + "update 0": { + "trials": 12, + "twoMutations": 0, + "duplicateEntities": null, + "trialsWithTwoEntities": null, + "genclassDuplicate": null, + "genclassFalse": null + }, + "update 30": { + "trials": 12, + "twoMutations": 0, + "duplicateEntities": null, + "trialsWithTwoEntities": null, + "genclassDuplicate": null, + "genclassFalse": null + }, + "update 80": { + "trials": 12, + "twoMutations": 0, + "duplicateEntities": null, + "trialsWithTwoEntities": null, + "genclassDuplicate": null, + "genclassFalse": null + }, + "update 150": { + "trials": 12, + "twoMutations": 0, + "duplicateEntities": null, + "trialsWithTwoEntities": null, + "genclassDuplicate": null, + "genclassFalse": null + } + }, + "byPage": { + "create collection": { + "trials": 12, + "twoMutations": 0, + "duplicateEntities": 0, + "trialsWithTwoEntities": 0, + "genclassDuplicate": null, + "genclassFalse": null + }, + "create product": { + "trials": 12, + "twoMutations": 0, + "duplicateEntities": 0, + "trialsWithTwoEntities": 0, + "genclassDuplicate": null, + "genclassFalse": null + }, + "create facet": { + "trials": 12, + "twoMutations": 0, + "duplicateEntities": 0, + "trialsWithTwoEntities": 0, + "genclassDuplicate": null, + "genclassFalse": null + }, + "create customer": { + "trials": 12, + "twoMutations": 0, + "duplicateEntities": 0, + "trialsWithTwoEntities": 0, + "genclassDuplicate": null, + "genclassFalse": null + }, + "update collection": { + "trials": 12, + "twoMutations": 0, + "duplicateEntities": null, + "trialsWithTwoEntities": null, + "genclassDuplicate": null, + "genclassFalse": null + }, + "update product": { + "trials": 12, + "twoMutations": 0, + "duplicateEntities": null, + "trialsWithTwoEntities": null, + "genclassDuplicate": null, + "genclassFalse": null + }, + "update facet": { + "trials": 12, + "twoMutations": 0, + "duplicateEntities": null, + "trialsWithTwoEntities": null, + "genclassDuplicate": null, + "genclassFalse": null + }, + "update customer": { + "trials": 12, + "twoMutations": 0, + "duplicateEntities": null, + "trialsWithTwoEntities": null, + "genclassDuplicate": null, + "genclassFalse": null + } + } + }, + "master-gc": { + "label": "refresh-master-gc", + "base": "http://localhost:5175", + "latencyMs": 0, + "genclass": true, + "failed": 0, + "errors": [ + "The email address is not available." + ], + "total": { + "create": { + "trials": 48, + "twoMutations": 25, + "duplicateEntities": 19, + "trialsWithTwoEntities": 19, + "genclassDuplicate": 24, + "genclassFalse": 0 + } + }, + "byActionGap": { + "create 0": { + "trials": 12, + "twoMutations": 12, + "duplicateEntities": 9, + "trialsWithTwoEntities": 9, + "genclassDuplicate": 12, + "genclassFalse": 0 + }, + "create 30": { + "trials": 12, + "twoMutations": 12, + "duplicateEntities": 9, + "trialsWithTwoEntities": 9, + "genclassDuplicate": 11, + "genclassFalse": 0 + }, + "create 80": { + "trials": 12, + "twoMutations": 1, + "duplicateEntities": 1, + "trialsWithTwoEntities": 1, + "genclassDuplicate": 1, + "genclassFalse": 0 + }, + "create 150": { + "trials": 12, + "twoMutations": 0, + "duplicateEntities": 0, + "trialsWithTwoEntities": 0, + "genclassDuplicate": 0, + "genclassFalse": 0 + } + }, + "byPage": { + "create collection": { + "trials": 12, + "twoMutations": 7, + "duplicateEntities": 7, + "trialsWithTwoEntities": 7, + "genclassDuplicate": 7, + "genclassFalse": 0 + }, + "create product": { + "trials": 12, + "twoMutations": 6, + "duplicateEntities": 6, + "trialsWithTwoEntities": 6, + "genclassDuplicate": 6, + "genclassFalse": 0 + }, + "create facet": { + "trials": 12, + "twoMutations": 6, + "duplicateEntities": 6, + "trialsWithTwoEntities": 6, + "genclassDuplicate": 5, + "genclassFalse": 0 + }, + "create customer": { + "trials": 12, + "twoMutations": 6, + "duplicateEntities": 0, + "trialsWithTwoEntities": 0, + "genclassDuplicate": 6, + "genclassFalse": 0 + } + } + }, + "patch-gc": { + "label": "refresh-patch-gc", + "base": "http://localhost:5177", + "latencyMs": 0, + "genclass": true, + "failed": 0, + "errors": [], + "total": { + "create": { + "trials": 48, + "twoMutations": 0, + "duplicateEntities": 0, + "trialsWithTwoEntities": 0, + "genclassDuplicate": 0, + "genclassFalse": 0 + } + }, + "byActionGap": { + "create 0": { + "trials": 12, + "twoMutations": 0, + "duplicateEntities": 0, + "trialsWithTwoEntities": 0, + "genclassDuplicate": 0, + "genclassFalse": 0 + }, + "create 30": { + "trials": 12, + "twoMutations": 0, + "duplicateEntities": 0, + "trialsWithTwoEntities": 0, + "genclassDuplicate": 0, + "genclassFalse": 0 + }, + "create 80": { + "trials": 12, + "twoMutations": 0, + "duplicateEntities": 0, + "trialsWithTwoEntities": 0, + "genclassDuplicate": 0, + "genclassFalse": 0 + }, + "create 150": { + "trials": 12, + "twoMutations": 0, + "duplicateEntities": 0, + "trialsWithTwoEntities": 0, + "genclassDuplicate": 0, + "genclassFalse": 0 + } + }, + "byPage": { + "create collection": { + "trials": 12, + "twoMutations": 0, + "duplicateEntities": 0, + "trialsWithTwoEntities": 0, + "genclassDuplicate": 0, + "genclassFalse": 0 + }, + "create product": { + "trials": 12, + "twoMutations": 0, + "duplicateEntities": 0, + "trialsWithTwoEntities": 0, + "genclassDuplicate": 0, + "genclassFalse": 0 + }, + "create facet": { + "trials": 12, + "twoMutations": 0, + "duplicateEntities": 0, + "trialsWithTwoEntities": 0, + "genclassDuplicate": 0, + "genclassFalse": 0 + }, + "create customer": { + "trials": 12, + "twoMutations": 0, + "duplicateEntities": 0, + "trialsWithTwoEntities": 0, + "genclassDuplicate": 0, + "genclassFalse": 0 + } + } + } + }, + "unitTests": { + "file": "src/lib/framework/form-engine/use-generated-form.spec.tsx", + "unfixed": "3 of 3 runs: 2 failed, 4 passed (the two submit-guard tests fail)", + "fixed": "3 of 3 runs: 6 passed", + "dashboardSuite": "fixed: 87 files passed, 1 skipped; 972 tests passed, 4 skipped" + }, + "e2eTests": { + "spec": "e2e/tests/catalog/collections.spec.ts -g \"Issue #2117\" --retries=0, Vendure Playwright 1.58.2 with executablePath /opt/pw-browsers/chromium", + "unfixed": "3 of 3 runs: Create and Update both fail (2 mutations each)", + "fixed": "3 of 3 runs: both pass", + "fixedWholeFile": "27 tests passed", + "fixedRepeatEach3": "6 of 6 passed" + }, + "checks": { + "checkTypes": "clean (fixed)", + "eslint": "no errors on the changed files", + "prettier": "clean on the changed source files; collections.spec.ts additions clean, two pre-existing deviations upstream (lines 802, 815) left alone" + } +} diff --git a/docs/adoption/hunts/vendure.md b/docs/adoption/hunts/vendure.md index 23a22ba0..126cb78e 100644 --- a/docs/adoption/hunts/vendure.md +++ b/docs/adoption/hunts/vendure.md @@ -1,9 +1,12 @@ # Race hunt: Vendure dashboard, duplicate submit on detail pages -Status: **reproduced, fix and regression tests ready, not sent.** Nothing was posted to vendurehq/vendure; the owner -sends the issue and PR drafted in [`vendure-issue.md`](./vendure-issue.md). The fix is -[`vendure.patch`](./vendure.patch) (against `master` at `aba3e9c`). The harness is -[`vendure-hunt.mjs`](./vendure-hunt.mjs). +Status: **ready to send (refreshed 2026-11-03, [section 9](#9-refresh-2026-11-03)).** Nothing was posted to +vendurehq/vendure. The owner sends the new issue and the PR drafted in [`vendure-issue.md`](./vendure-issue.md); the +draft recommends a new dashboard issue and no comment on #2117. The fix is [`vendure.patch`](./vendure.patch), against +`master` at `f97b187` (2026-11-02); it also applies to `aba3e9c`. The harness is +[`vendure-hunt.mjs`](./vendure-hunt.mjs). Sections 1 to 8 are the record of the 2026-10-24 hunt at `aba3e9c`. Since +the refresh, the patch's e2e test lives in `e2e/tests/catalog/collections.spec.ts`, not in +`e2e/tests/regression/double-submit-on-save.spec.ts` (section 9.3). ## Summary @@ -465,3 +468,208 @@ Network Access block and the awaited-click limitation), 10 min on the root cause the unit and e2e tests (three builds each), 30 min of sweeps (two in parallel; the plan of 10 trials everywhere was cut to 3 for the secondary cells to finish sooner), 10 min on the admin-ui and the write-up. One sweep pair was lost and rerun when the dev server hit the session's background time limit. + +## 9. Refresh (2026-11-03) + +A cloud session re-ran the hunt on today's `master`, rebased the patch, re-ran every test and the reproduction without +GenClass, checked the tracker and the contribution rules, and rewrote the drafts. Summary of every number below: +[`vendure-refresh/summary.json`](./vendure-refresh/summary.json), produced from the harness's raw run files by +[`vendure-refresh/summarize.mjs`](./vendure-refresh/summarize.mjs). The raw files are not committed. + +**Status: ready to send.** The sender has only the by-hand items in +[`vendure-issue.md`](./vendure-issue.md#before-filing-what-the-2026-11-03-refresh-did-and-what-is-left) left: search +once more on the day, reproduce by hand, apply the patch and run the tests, read the diff, file the issue, open the PR, +sign the CLA. + +### 9.1 Versions and setup + +| what | 2026-10-24 hunt | 2026-11-03 refresh | +|---|---|---| +| upstream | `vendurehq/vendure` `master` `aba3e9c` | `master` `f97b187` (2026-11-02, "docs: Regenerate manifest.json"), 25 commits later | +| packages | 3.8.1 | 3.8.1 (no release in between) | +| runtime | Node 22.22.0, bun 1.4.4 | Node 22.23.4, bun 1.4.4 | +| browser | Chromium 153.0.8010.12 | the same | +| GenClass | `d77ffd8`, runtime 0.3.0-beta.1, `@genclass/mcp` 0.1.0 | `c1c9e14` (this repo's `main`), built `packages/runtime/dist` and `@genclass/mcp` 0.2.1, in-process | + +Only one of the 25 new commits touches the dashboard's e2e or framework folders. It is `eb09daff8` (user settings +after logout; `e2e/tests/auth/login.spec.ts`, `sales/orders.spec.ts`), and it changes none of the patched files. +`use-detail-page.ts` and `use-generated-form.tsx` are byte-identical to `aba3e9c`, so every line number in the +drafts still holds (446, 379, 418, 430; 293 to 351, 297, 348, 350). The Create buttons cited are unchanged too +(`collections_.$id.tsx` 171, `products_.$id.tsx` 245). + +Commands (no Docker daemon; SQLite): + +```bash +git clone https://github.com/vendure-ecommerce/vendure ~/targets/vendure # redirects to vendurehq/vendure +cd ~/targets/vendure && bun install +NODE_OPTIONS=--max-old-space-size=8192 bun run build # all 18 projects, the dashboard's check-types included +cd packages/dev-server && DB=sqlite bun run populate +VENDURE_DISABLE_TELEMETRY=true DB=sqlite bun run dev:server # the API on :3000, no watchers +cd ../dashboard +VITE_ADMIN_API_PORT=3000 VITE_ADMIN_API_HOST=http://localhost npx vite build --outDir ~/targets/dash-buggy --emptyOutDir +npx vite preview --outDir ~/targets/dash-buggy --port 5175 --strictPort # unfixed; the patch's build on :5177 +``` + +Two differences from the 2026-10-24 setup: + +- With the 8 GB heap, the full `bun run build` passed, the dashboard's type check included. The hunt had to build the + dashboard separately. +- The sweeps ran against `dev:server` alone, not `dev:direct`. `dev:direct` watches the source tree, and a + `vite build` of the dashboard rewrites `packages/dev-server/graphql/*.ts`. That restarted the server twice, and + then the workflow's watchers exited (logged `SIGTERM`), taking the API down. `bun run start` (server and worker) did + not come up either: both processes open the same SQLite file and the second got `SqliteError: database is locked`. + The manual reproduction in the issue uses `dev:direct`, which works as long as nothing rebuilds the dashboard + meanwhile. + +### 9.2 The reproduction without GenClass, re-run + +`GENCLASS=0` sweeps, 3 trials per page and gap (4 pages, gaps 0, 30, 80 and 150 ms), local latency, CDP double +press, production builds: + +| variant | Create: trials with 2 mutations | duplicate entities in the DB | Update: trials with 2 mutations | failed trials | +|---|---|---|---|---| +| `master` `f97b187` | **21 of 48** (12/12 at 0 ms, 9/12 at 30 ms, 0/12 at 80, 0/12 at 150) | **15** (Collection 6, Product 6, Facet 3; Customer 0: second create rejected, "The email address is not available.") | **25 of 48** (12/12, 9/12, 4/12, 0/12) | 0 | +| patch | **0 of 48** (48 entities from 48 trials) | **0** | **0 of 48** | 0 | + +It still reproduces on today's `master` in the same pattern as the hunt's control (18 of 48 Create trials, 15 +duplicate entities). With the patch, every trial sent exactly one mutation and got one successful response. The +second press still hit an enabled button in 37 of the 96 patched trials, and the new submit guard ignored it. + +Run with: + +```bash +BASE=http://localhost:5175 GENCLASS=0 TRIALS=3 VENDURE=$HOME/targets/vendure OUT=master-nogc.json node docs/adoption/hunts/vendure-hunt.mjs +BASE=http://localhost:5177 GENCLASS=0 TRIALS=3 VENDURE=$HOME/targets/vendure OUT=patch-nogc.json node docs/adoption/hunts/vendure-hunt.mjs +``` + +PR #5007 (merged 2026-08-26, released in 3.8.0, already in `aba3e9c`) added a unique constraint on translation rows +for concurrent saves. All of these counts were measured with it in place. It does not stop a double Create from +making two entities. + +### 9.3 The patch, rebased, and its tests + +The patch applied to `f97b187` without changes. One change was made for Vendure's own rules. Their `AGENTS.md` +(present at `aba3e9c` too; the hunt missed it) says for dashboard e2e tests: "**always check existing suites +first** … `tests/regression/` — **only** for tests that genuinely don't fit any existing suite", and "Add a comment +referencing the issue number above the test". The test therefore moved from a new +`e2e/tests/regression/double-submit-on-save.spec.ts` into the existing `e2e/tests/catalog/collections.spec.ts`. It is +appended as `test.describe('Issue #2117: Double-click on Create or Update submits once', ...)` with a `// #2117 — ...` +comment, the pattern of that file's other issue blocks. + +While moving it, two robustness problems in the 2026-10-24 test were fixed: + +- **Cleanup by name could delete another worker's data.** The dashboard's Playwright config is `fullyParallel`, so + the two tests can run in different workers, each with its own `beforeAll` target. `afterAll` deleted every + collection named `E2E Double Click Target`, which included another worker's target in the middle of its Update test. + It now deletes only the ids its own worker created. +- **A retry could fail on its own leftovers.** The created name was fixed at module load, so a CI retry (`retries: 1` + under `CI`) of Create in the same worker would find two collections with that name. Names are now made per test. + +A `test.describe.configure({ mode: 'serial' })` was tried and dropped. With it, a failing Create skips Update, so the +unfixed run hid the Update failure. + +The patch is now 4 files, 240 added and 9 removed lines. It adds no file and no dependency. The source changes are +identical to the hunt's. + +| test (3 runs each way) | `master` `f97b187` | patch | +|---|---|---| +| `use-generated-form.spec.tsx`: second submit during validation ignored | fails 3/3 | passes 3/3 | +| `use-generated-form.spec.tsx`: submits ignored until the `onSubmit` promise settles | fails 3/3 | passes 3/3 | +| `use-generated-form.spec.tsx`: a submit that failed validation releases the guard | passes 3/3 | passes 3/3 | +| `collections.spec.ts` `Issue #2117`: double-clicking Create creates one collection (`dblclick()`) | fails 3/3 (2 `createCollection` mutations) | passes 3/3 | +| `collections.spec.ts` `Issue #2117`: double-clicking Update sends one update | fails 3/3 (2 `updateCollection` mutations) | passes 3/3 | + +With the patch: + +- `npm run check-types` is clean. +- The dashboard unit suite (`npx vitest run`) passed: 87 files, 972 tests passed, 4 skipped. +- The whole `collections.spec.ts` passed (27 tests), and the new block passed with `--repeat-each=3` (6 of 6). +- eslint reported no errors on the changed files. It ignores `e2e/` and `*.spec.tsx` by configuration and warns + "File ignored". +- prettier is clean on the changed source files. Upstream's `collections.spec.ts` already has two prettier deviations + of its own (lines 802 and 815). The patch adds only lines and leaves those alone. + +The e2e runs used Vendure's harness, with this command from `packages/dashboard`: +`CI=true VITE_TEST_PORT=5176 npx playwright test --config e2e/playwright.local.config.ts e2e/tests/catalog/collections.spec.ts -g "Issue #2117" --retries=0`. +The local wrapper config only points Playwright 1.58.2 at `/opt/pw-browsers/chromium`, and it is not in the patch. +The dashboard's full e2e suite and the plugin unit config (`vitest.plugin.config.mts`) were not run: they touch +nothing the patch changes, and there was not enough time. + +### 9.4 Tracker, rules, and the #2117 decision + +Searches went through `gh api -X GET search/issues` on `repo:vendurehq/vendure`, 3 seconds apart, each as `is:issue` +and as `is:pull-request`, for the 8 queries in the draft. They found nothing filed on the dashboard's double submit +since 2026-10-24. (The old name `vendure-ecommerce/vendure` answers 422 to the search API; the clone URL +redirects.) Related: + +- **#2117** (read 2026-11-03) is unchanged since the hunt's description: open, `@vendure/admin-ui`, assigned to + michaelbromley, last comment dlhck 2025-04-02, no linked PR. The only later event is a `T3: Systemic` label added and + removed on 2026-07-17, with no comment. +- **#4884** (opened 2026-06-27, closed 2026-09-30 by #5007) is about duplicate translation rows from a double-click + save in the legacy Admin UI. Its body says "the React dashboard does avoid the double-click". The measurements above + contradict that for 3.8.1. The new issue says so, with the steps. +- **#5007** (`fix(core): prevent duplicate translations from concurrent saves`, merged 2026-08-26 into `minor`) is + server-side only. It touches no dashboard files and does not mention #2117. + +Rules, read in the clone at `f97b187`: + +- `CONTRIBUTING.md` asks for an issue first for a bug fix ("please first create an issue detailing the bug, and + indicate that you intend to fix it"). Bug fixes go to `master`. Commit messages are Conventional Commits with a + lower-case subject, linted; PR titles are checked the same way. A bot asks for the CLA when the PR opens. There is + no DCO. +- `.github/ISSUE_TEMPLATE/config.yml` turns blank issues off, so the bug template is the way in. +- `AGENTS.md` asks for `Fixes #N` or `Relates to #N` in the commit body, and sets the e2e test placement above. +- Neither file has an AI or LLM policy. 36 of the 270 commits on `master` since 2026-10-01 carry a + `Co-authored-by: Claude` trailer. + +The draft therefore keeps the swarm's default disclosure paragraph, now added at the end of both the issue and the PR +text. The 2026-10-24 drafts had only a one-line GenClass mention and no AI-assistance paragraph. + +Decision: **a new dashboard issue, no comment on #2117** (reasons in the draft). `Fixes #2117` would close the +admin-ui report, which the patch does not fix and which still reproduces (section 6). After filing, the sender +replaces `#2117` in the patch's comments and test title with the new number. + +### 9.5 GenClass, re-run on the same builds + +The GenClass sweeps used `GENCLASS=1`, Create only, 3 trials per page and gap: + +| run | trials with 2 mutations | duplicate witness on them | missed | trials with 1 mutation | false duplicate reports | +|---|---|---|---|---|---| +| `master` | 25 of 48 | 24 | 1 (Facet, 30 ms gap) | 23 | 0 | +| patch | 0 of 48 | 0 | 0 | 48 | 0 | + +That matches the hunt: the witness still misses some trials at a 30 ms gap, and it never fired on a single submit. +The control sweep without GenClass gives the same pattern, so the injection does not change the app. + +### 9.6 Problems with GenClass itself (new or changed) + +- **The TanStack `timer 0ms` false `stale-write` got more frequent.** It reads, for example: the response "overwrote + `pageHeading.external#.isPending` after a newer `timer 0ms` had already written it". On 2026-10-24 it appeared on + about half of the clean patched runs. On 2026-11-03, with `c1c9e14` and `@genclass/mcp` 0.2.1, it appeared on 12 of + 12 patched runs on every page, for both `.isPending` and `.status` (8 of the 13 findings). The cause is the same as + section 7 describes: TanStack Query's notify manager batches its `useMutation` state from a `setTimeout(0)`, and + the agent link treats that timer as a newer operation. +- **New `stale-write` findings on base-ui component state** came up on the patched build: a response "overwrote + `baseUiR#k.state#` after the user's `click button "base-ui-_r_#k_"`", on 1 or 2 runs of a page (4 findings). The + page had no visible fault. This is likely another false report; it was not diagnosed. +- **Some `stale-write` findings were true and duplicate the witness.** On unfixed `master`, react-hook-form state + findings (`pageHeading.state#.submitCount`, `.isSubmitted`, `.isSubmitSuccessful`: the first response overwrote + them after the second "submit form") occurred in exactly as many runs per page as trials with two mutations + (Collection 7, Facet 6, Customer 6; Product's were not among the 20 findings the bridge lists). They are a consequence of the duplicate submit, but each double submit + shows up as about seven findings plus the timer noise above. The `genclass_findings` list (20 shown) is therefore + no help to a reader. The hunt again reports only the duplicate-request witness. +- **The swarm docs name the upstream `vendure-ecommerce/vendure`.** `gh api search/issues` rejects that name + (HTTP 422, "cannot be searched"). The canonical name is `vendurehq/vendure`. + +### 9.7 Time spent + +About 1 h 15 min wall clock (2026-11-03, 16:30 to 17:45 UTC): + +| step | time | +|---|---| +| reading the brief, the drafts and the rules; the tracker search | 10 min | +| clone, `bun install`, full build | 12 min | +| the dev server's restart and lock problems | 8 min | +| four sweeps of 48 or 96 trials, with the drafts written in parallel | 30 min | +| unit and e2e runs (three each way, twice because of the serial-mode detour) | 15 min | +| the write-up | the rest | diff --git a/docs/adoption/hunts/vendure.patch b/docs/adoption/hunts/vendure.patch index 74dc549c..5a5fbe6f 100644 --- a/docs/adoption/hunts/vendure.patch +++ b/docs/adoption/hunts/vendure.patch @@ -1,34 +1,19 @@ -diff --git a/packages/dashboard/e2e/tests/regression/double-submit-on-save.spec.ts b/packages/dashboard/e2e/tests/regression/double-submit-on-save.spec.ts -new file mode 100644 -index 0000000..42fc2f0 ---- /dev/null -+++ b/packages/dashboard/e2e/tests/regression/double-submit-on-save.spec.ts -@@ -0,0 +1,121 @@ -+import { type Page, expect, test } from '@playwright/test'; -+ -+import { BaseDetailPage } from '../../page-objects/detail-page.base.js'; -+import { VendureAdminClient } from '../../utils/vendure-admin-client.js'; +diff --git a/packages/dashboard/e2e/tests/catalog/collections.spec.ts b/packages/dashboard/e2e/tests/catalog/collections.spec.ts +index 39f5be770..1dfba789c 100644 +--- a/packages/dashboard/e2e/tests/catalog/collections.spec.ts ++++ b/packages/dashboard/e2e/tests/catalog/collections.spec.ts +@@ -871,3 +871,120 @@ test.describe('Issue #5393: Collections Move dialog filter input', () => { + await expect(dialog.getByRole('button', { name: COLLECTION_A_NAME })).not.toBeVisible(); + }); + }); + -+// #2117: a double-click on the save button of a detail page must send the create or update ++// #2117 — a double-click on the Create or Update button of a detail page must send the +// mutation only once. The form validates asynchronously before it calls the mutation, and the +// button is only disabled on the next render, so the second click used to submit the form again +// and, on a "new" page, create the entity twice. -+ -+/** Counts the admin-api requests that carry the given mutation. */ -+function countMutations(page: Page, mutationName: string) { -+ const sent: string[] = []; -+ page.on('request', request => { -+ const body = request.postData() ?? ''; -+ if (request.method() === 'POST' && body.includes('mutation') && body.includes(`${mutationName}(`)) { -+ sent.push(body); -+ } -+ }); -+ return sent; -+} -+ -+test.describe('Double-click on the save button submits once (#2117)', () => { -+ const createdName = `E2E Double Click Create ${Date.now()}`; -+ const updatedName = `E2E Double Click Update ${Date.now()}`; ++test.describe('Issue #2117: Double-click on Create or Update submits once', () => { ++ const TARGET_NAME = 'E2E Double Click Target'; ++ const createdIds: string[] = []; + let updateTargetId: string; + + const detailConfig = { @@ -37,6 +22,22 @@ index 0000000..42fc2f0 + newTitle: 'New collection', + }; + ++ /** Collects the admin-api requests that carry the given mutation. */ ++ function captureMutations(page: Page, mutationName: string) { ++ const sent: string[] = []; ++ page.on('request', request => { ++ const body = request.postData() ?? ''; ++ if ( ++ request.method() === 'POST' && ++ body.includes('mutation') && ++ body.includes(`${mutationName}(`) ++ ) { ++ sent.push(body); ++ } ++ }); ++ return sent; ++ } ++ + test.beforeAll(async ({ browser }) => { + const page = await browser.newPage(); + const client = new VendureAdminClient(page); @@ -51,7 +52,7 @@ index 0000000..42fc2f0 + translations: [ + { + languageCode: 'en', -+ name: 'E2E Double Click Target', ++ name: TARGET_NAME, + slug: `e2e-double-click-target-${Date.now()}`, + description: '', + }, @@ -60,6 +61,7 @@ index 0000000..42fc2f0 + }, + ); + updateTargetId = createCollection.id as string; ++ createdIds.push(updateTargetId); + await page.close(); + }); + @@ -67,13 +69,7 @@ index 0000000..42fc2f0 + const page = await browser.newPage(); + const client = new VendureAdminClient(page); + await client.login(); -+ const { collections } = await client.gql( -+ `query ($names: [String!]!) { -+ collections(options: { filter: { name: { in: $names } } }) { items { id } } -+ }`, -+ { names: [createdName, updatedName, 'E2E Double Click Target'] }, -+ ); -+ for (const { id } of collections.items) { ++ for (const id of createdIds) { + await client.gql(`mutation ($id: ID!) { deleteCollection(id: $id) { result } }`, { id }); + } + await page.close(); @@ -81,7 +77,8 @@ index 0000000..42fc2f0 + + test('double-clicking Create creates one collection', async ({ page }) => { + const detail = new BaseDetailPage(page, detailConfig); -+ const creates = countMutations(page, 'createCollection'); ++ const creates = captureMutations(page, 'createCollection'); ++ const createdName = `E2E Double Click Create ${Date.now()}`; + + await detail.gotoNew(); + await detail.expectNewPageLoaded(); @@ -95,27 +92,28 @@ index 0000000..42fc2f0 + // A second submit would have been sent by now + await page.waitForTimeout(1000); + -+ expect(creates).toHaveLength(1); + const client = new VendureAdminClient(page); + await client.login(); + const { collections } = await client.gql( + `query ($name: String!) { -+ collections(options: { filter: { name: { eq: $name } } }) { totalItems } ++ collections(options: { filter: { name: { eq: $name } } }) { items { id } } + }`, + { name: createdName }, + ); -+ expect(collections.totalItems).toBe(1); ++ createdIds.push(...collections.items.map((item: { id: string }) => item.id)); ++ expect(creates).toHaveLength(1); ++ expect(collections.items).toHaveLength(1); + }); + + test('double-clicking Update sends one update', async ({ page }) => { + const detail = new BaseDetailPage(page, detailConfig); -+ const updates = countMutations(page, 'updateCollection'); ++ const updates = captureMutations(page, 'updateCollection'); + + await detail.gotoExisting(updateTargetId); -+ await expect(detail.formItem('Name').getByRole('textbox')).toHaveValue('E2E Double Click Target', { ++ await expect(detail.formItem('Name').getByRole('textbox')).toHaveValue(TARGET_NAME, { + timeout: 10_000, + }); -+ await detail.fillInput('Name', updatedName); ++ await detail.fillInput('Name', `E2E Double Click Update ${Date.now()}`); + await expect(detail.updateButton).toBeEnabled(); + + await detail.updateButton.dblclick(); @@ -126,7 +124,7 @@ index 0000000..42fc2f0 + }); +}); diff --git a/packages/dashboard/src/lib/framework/form-engine/use-generated-form.spec.tsx b/packages/dashboard/src/lib/framework/form-engine/use-generated-form.spec.tsx -index bc2def4..42eca38 100644 +index bc2def4b1..42eca38cf 100644 --- a/packages/dashboard/src/lib/framework/form-engine/use-generated-form.spec.tsx +++ b/packages/dashboard/src/lib/framework/form-engine/use-generated-form.spec.tsx @@ -131,3 +131,91 @@ describe('useGeneratedForm with a nullable: false custom field', () => { @@ -222,7 +220,7 @@ index bc2def4..42eca38 100644 + }); +}); diff --git a/packages/dashboard/src/lib/framework/form-engine/use-generated-form.tsx b/packages/dashboard/src/lib/framework/form-engine/use-generated-form.tsx -index d4559fb..5468668 100644 +index d4559fb37..5468668a1 100644 --- a/packages/dashboard/src/lib/framework/form-engine/use-generated-form.tsx +++ b/packages/dashboard/src/lib/framework/form-engine/use-generated-form.tsx @@ -123,10 +123,17 @@ export interface GeneratedFormOptions< @@ -289,7 +287,7 @@ index d4559fb..5468668 100644 } diff --git a/packages/dashboard/src/lib/framework/page/use-detail-page.ts b/packages/dashboard/src/lib/framework/page/use-detail-page.ts -index 60ab050..f4f798a 100644 +index 60ab05031..f4f798ae4 100644 --- a/packages/dashboard/src/lib/framework/page/use-detail-page.ts +++ b/packages/dashboard/src/lib/framework/page/use-detail-page.ts @@ -412,10 +412,14 @@ export function useDetailPage<