From 81eb2e00beb874a56fee1500beb904d3b0512d6d Mon Sep 17 00:00:00 2001 From: Segun Adebayo Date: Fri, 26 Jun 2026 12:28:25 +0200 Subject: [PATCH 1/2] fix(tags-input): sync hidden input on value change for native form submit Wire dispatchChangeEvent when tags change so FormData reflects the current value instead of staying frozen at mount time (fixes #3193). Adds a form example, e2e coverage, and shared clickButton/seeTextValue model helpers. --- .../tags-input-hidden-input-stale-value.md | 8 ++ e2e/models/model.ts | 8 ++ e2e/models/tags-input.model.ts | 4 +- e2e/models/tree-view.model.ts | 4 - e2e/tags-input.e2e.ts | 27 ++++++ examples/next-ts/pages/tags-input/form.tsx | 84 +++++++++++++++++++ .../tags-input/src/tags-input.machine.ts | 7 +- shared/src/routes.ts | 1 + 8 files changed, 136 insertions(+), 7 deletions(-) create mode 100644 .changeset/tags-input-hidden-input-stale-value.md create mode 100644 examples/next-ts/pages/tags-input/form.tsx diff --git a/.changeset/tags-input-hidden-input-stale-value.md b/.changeset/tags-input-hidden-input-stale-value.md new file mode 100644 index 0000000000..a26b017cab --- /dev/null +++ b/.changeset/tags-input-hidden-input-stale-value.md @@ -0,0 +1,8 @@ +--- +"@zag-js/tags-input": patch +--- + +Fix native form submit so `FormData` reflects the current tags, not the mount-time value (#3193). + +After adding, removing, or clearing tags, the hidden input kept its initial value — e.g. with `defaultValue={["a"]}`, +adding `"b"` and submitting sent `"a"` instead of `"a, b"`. The hidden input now stays in sync on every value change. diff --git a/e2e/models/model.ts b/e2e/models/model.ts index 8efbc3c53a..d0207dd1ac 100644 --- a/e2e/models/model.ts +++ b/e2e/models/model.ts @@ -130,6 +130,14 @@ export class Model { return this.page.getByText(text).click() } + clickButton(name: string, options?: { modifiers?: Array<"Alt" | "Control" | "Meta" | "Shift"> }) { + return this.page.getByRole("button", { name }).first().click(options) + } + + seeTextValue(selector: string, value: string) { + return expect(this.page.locator(selector)).toHaveText(value) + } + see(text: string, context?: string) { const locator = context ? this.page.locator(context).getByText(text) : this.page.getByText(text) return expect(locator).toBeVisible() diff --git a/e2e/models/tags-input.model.ts b/e2e/models/tags-input.model.ts index 02283bd634..df37b8b42c 100644 --- a/e2e/models/tags-input.model.ts +++ b/e2e/models/tags-input.model.ts @@ -6,8 +6,8 @@ export class TagsInputModel extends Model { super(page) } - goto() { - return this.page.goto("/tags-input/basic") + goto(url = "/tags-input/basic") { + return this.page.goto(url) } private get input() { diff --git a/e2e/models/tree-view.model.ts b/e2e/models/tree-view.model.ts index 106bc68d0b..55858ee42e 100644 --- a/e2e/models/tree-view.model.ts +++ b/e2e/models/tree-view.model.ts @@ -42,10 +42,6 @@ export class TreeViewModel extends Model { return this.branchTrigger(name).click(options) } - clickButton(name: string, options?: ClickOptions) { - return this.button(name).click(options) - } - focusItem(name: string) { return this.item(name).focus() } diff --git a/e2e/tags-input.e2e.ts b/e2e/tags-input.e2e.ts index 1de0b1b1df..16b1fffac5 100644 --- a/e2e/tags-input.e2e.ts +++ b/e2e/tags-input.e2e.ts @@ -313,3 +313,30 @@ test.describe("tags-input", () => { await I.seeInputIsFocused() }) }) + +test.describe("tags-input / form", () => { + test.beforeEach(async ({ page }) => { + I = new TagsInputModel(page) + await I.goto("/tags-input/form") + }) + + test("native submit reflects tags added after mount", async () => { + await I.addTag("Svelte") + await I.clickButton("Submit") + await I.seeTextValue("[data-testid=last-submit]", "React, Vue, Svelte") + }) + + test("native submit reflects tags removed after mount", async () => { + await I.clickTagClose("Vue") + await I.dontSeeTag("Vue") + await I.clickButton("Submit") + await I.seeTextValue("[data-testid=last-submit]", "React") + }) + + test("native submit reflects cleared tags", async () => { + await I.clickClearTrigger() + await I.seeNoTags() + await I.clickButton("Submit") + await I.seeTextValue("[data-testid=last-submit]", "") + }) +}) diff --git a/examples/next-ts/pages/tags-input/form.tsx b/examples/next-ts/pages/tags-input/form.tsx new file mode 100644 index 0000000000..40f250ae14 --- /dev/null +++ b/examples/next-ts/pages/tags-input/form.tsx @@ -0,0 +1,84 @@ +import { normalizeProps, useMachine } from "@zag-js/react" +import { tagsInputControls } from "@zag-js/shared" +import * as tagsInput from "@zag-js/tags-input" +import { useId, useState } from "react" +import { StateVisualizer } from "../../components/state-visualizer" +import { Toolbar } from "../../components/toolbar" +import { useControls } from "../../hooks/use-controls" + +function toDashCase(str: string) { + return str.replace(/\s+/g, "-").toLowerCase() +} + +export default function Page() { + const controls = useControls(tagsInputControls) + + const [submitCount, setSubmitCount] = useState(0) + const [lastSubmit, setLastSubmit] = useState(null) + + const service = useMachine(tagsInput.machine, { + id: useId(), + name: "tags", + defaultValue: ["React", "Vue"], + ...controls.context, + }) + + const api = tagsInput.connect(service, normalizeProps) + + return ( + <> +
+
{ + e.preventDefault() + const data = new FormData(e.currentTarget) + const value = String(data.get("tags") ?? "") + setSubmitCount((c) => c + 1) + setLastSubmit(value) + }} + > +
+ +
+ {api.value.map((value, index) => ( + +
+ + {value}{" "} + + +
+ +
+ ))} + + +
+ +
+ +
+ +
+
+ +
+ submit count: {submitCount} +
+ last submitted value: {lastSubmit ?? "—"} +
+
+ + + + + + ) +} diff --git a/packages/machines/tags-input/src/tags-input.machine.ts b/packages/machines/tags-input/src/tags-input.machine.ts index 9566055e2f..beb3b00829 100644 --- a/packages/machines/tags-input/src/tags-input.machine.ts +++ b/packages/machines/tags-input/src/tags-input.machine.ts @@ -97,6 +97,9 @@ export const machine = createMachine({ }, watch({ track, context, action, computed, refs }) { + track([() => computed("valueAsString")], () => { + action(["dispatchChangeEvent"]) + }) track([() => context.get("editedTagValue")], () => { action(["syncEditedTagInputValue"]) }) @@ -438,7 +441,9 @@ export const machine = createMachine({ send({ type: "EXTERNAL_BLUR", id: event.id }) }, dispatchChangeEvent({ scope, computed }) { - dom.dispatchInputEvent(scope, computed("valueAsString")) + queueMicrotask(() => { + dom.dispatchInputEvent(scope, computed("valueAsString")) + }) }, highlightNextTag({ context, scope }) { const highlightedTagId = context.get("highlightedTagId") diff --git a/shared/src/routes.ts b/shared/src/routes.ts index 5abee0cea0..ce1f686726 100644 --- a/shared/src/routes.ts +++ b/shared/src/routes.ts @@ -446,6 +446,7 @@ export const componentRoutes: ComponentRoute[] = [ label: "Tags Input", examples: [ { slug: "basic", title: "Basic" }, + { slug: "form", title: "Form" }, { slug: "validate", title: "Validate" }, { slug: "allow-duplicates", title: "Allow Duplicates" }, { slug: "sentence-builder", title: "Builder" }, From 280b37ff50e1148166a46a1ab1297bdffd615ef0 Mon Sep 17 00:00:00 2001 From: Segun Adebayo Date: Fri, 26 Jun 2026 12:36:27 +0200 Subject: [PATCH 2/2] docs(changeset): tighten tags-input form submit wording --- .changeset/tags-input-hidden-input-stale-value.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.changeset/tags-input-hidden-input-stale-value.md b/.changeset/tags-input-hidden-input-stale-value.md index a26b017cab..3693abd0e0 100644 --- a/.changeset/tags-input-hidden-input-stale-value.md +++ b/.changeset/tags-input-hidden-input-stale-value.md @@ -2,7 +2,6 @@ "@zag-js/tags-input": patch --- -Fix native form submit so `FormData` reflects the current tags, not the mount-time value (#3193). +Fix native form submit so `FormData` reflects the current tags (#3193). -After adding, removing, or clearing tags, the hidden input kept its initial value — e.g. with `defaultValue={["a"]}`, -adding `"b"` and submitting sent `"a"` instead of `"a, b"`. The hidden input now stays in sync on every value change. +The hidden input kept its initial value after tags were added, removed, or cleared. With `defaultValue={["a"]}`, adding `"b"` and submitting sent `"a"` instead of `"a, b"`.