Skip to content

Field constraints are invisible until the server rejects the save, and autosave then retries the doomed request indefinitely #2417

Closed

Description

@virafb

Description

A collection field can declare validation constraints (maxLength, min, minItems, pattern). These are enforced server-side by the generated Zod schema, but nothing in the admin surfaces them to the person typing. The editor's first and only signal is a failed save.

Since #1838 that failure message is at least specific — the toast reads excerpt: Too big: expected string to have <=160 characters rather than the old generic "Invalid request data", which is a real improvement. But it arrives after the fact, names the field by slug rather than by its label, uses validator-internal phrasing, and — the substantive part of this report — autosave keeps retrying a request that can never succeed.

In one editing session on 0.32.0 we recorded 443 failed PUTs to the same entry over 23.4 minutes, a median of 3.0 seconds apart (~19/min), every one a 400 with the identical body. Each produced its own toast. The author had added a few words to a summary field capped at 160 characters; nothing in the UI had indicated a cap existed.

Steps to reproduce

  1. Declare a text field with validation: { maxLength: 160 } in a collection.
  2. Open an entry in the admin and type past 160 characters in that field.
  3. Observe: no counter, no maxlength, no help text — nothing marks the limit while typing.
  4. Wait. Autosave fires, fails 400, and toasts excerpt: Too big: expected string to have <=160 characters.
  5. Leave the tab open. Autosave retries every ~3 seconds indefinitely, toasting each time, with no back-off and no terminal state.

Observed

  • The constraint is unknowable from the UI until it is violated.
  • The message identifies the field by slug (excerpt), not by the label the editor actually sees ("Summary").
  • The message is the raw validator string ("Too big: expected string to have <=160 characters") rather than a sentence aimed at an author.
  • Autosave treats a permanent 400 as retryable. 443 attempts, 23.4 minutes, no escalation, no stop, no back-off.

Expected

  • Constraints declared in the schema are visible while editing — at minimum a character counter or maxlength on length-limited fields, and help text derived from validation rather than hand-written into the label.
  • Validation errors name the field by its label.
  • Autosave treats a 4xx other than 429 as terminal: stop retrying, keep the buffer dirty, surface the problem once, and retry only when the content changes again.

Why this matters beyond one field

We audited our own schema after hitting this: 16 of 17 declared constraints never reach the editor's screen. The single exception is the field in this report, and only because a developer had previously hand-typed "max 160 chars" into its label — after an earlier incident. That workaround is unchecked: change maxLength to 200 and the label still says 160.

The options constraints are effectively surfaced, because a select renders the allowed set. Everything expressed as a number or a pattern — maxLength, min, minItems, pattern — is invisible. A non-technical author cannot discover any of it except by tripping it.

Related

Environment

  • emdash 0.32.0
  • Astro node adapter, PostgreSQL 18 adapter
  • Reproduced against a production-shaped content set; server responses captured from load-balancer access logs, so the retry counts above are measured rather than estimated.

Notes on possible fixes

Roughly in increasing order of effort:

  1. Stop the retry storm. Classify non-429 4xx as terminal in the autosave client. This alone removes 442 of the 443 requests and the toast pile.
  2. Use the label in the message. The field descriptor already carries both slug and label.
  3. Render the constraint. Pass validation through to the field renderer: maxlength plus a live counter for length-limited strings, min/max on numerics, and generated help text. This is the fix that would have prevented the situation entirely.

Happy to open a PR for (1) and (2) if that would be useful — they look self-contained, and (1) is the one that turns a papercut into a non-event.

Activity

  1. added this to the 1.0 milestone on Aug 13, 2026
  2. virafb commented on Aug 14, 2026

    @virafb
    Author

    A concrete, small fix for the "constraints are invisible" half — the data is already at the render site.

    Checked against @emdash-cms/admin@0.32.0 and 0.33.0, src/components/ContentEditor.tsx:

    case "string" (line 1170 in 0.32.0) renders a bare <Input> — no maxLength attribute and no character counter:

    case "string":
      return (
        <Input
          label={<span className={labelClass}>{label}</span>}
          id={id}
          value={typeof value === "string" ? value : ""}
          onChange={(e) => handleChange(e.target.value)}
          required={field.required}
          dir="auto"
          …
        />
      );

    The field object in that same switch already carries validation (declared line 93), and the component reads it elsewhere — validation.allowedMimeTypes for media fields (1326, 1350) and validation.minItems / maxItems for repeaters (1376–1377). Only the string case ignores it.

    So passing maxLength={field.validation?.maxLength} to the Input, and/or rendering a live count against it, needs no new plumbing. A native maxLength alone would prevent the doomed save entirely, which also sidesteps the retry loop reported above.

    Worth noting the same gap for text fields, and that the author has no way to measure themselves against a limit even when they know the number: we worked around this by generating the limit into the field's own label from validation, and it only half helps — you are told "max 160 characters" with nothing counting.

    Plugin widgets can't fill the gap either. field.widget resolves to a plugin field component, but the props passed to it are value, onChange, label, id, required, options, minimal — validation is not among them. An integrator writing a counting widget has to hardcode the number, which then drifts from the one the server enforces.

  3. danielmlr commented on Aug 25, 2026

    @danielmlr
    Contributor

    Thanks for numbering the fixes. You had offered to take (1) and (2) — hope you don't mind that I picked up (1). Do you still want (2)? If so I will leave it with you, and I would look at (3) only if the sketch in your comment above is not already headed for a PR of its own. Say the word and I stay off both.

    #2614 covers (1). A client error is now terminal unless it is 408, 421, 425 or 429, and the editor schedules no further autosave while the form still matches the rejected payload. Manual Save keeps working and the next content change resumes autosaving; 5xx and network failures still retry.

  4. ascorbic commented on Sep 2, 2026

    @ascorbic
    Member

    Thanks both. #2614 completes item 1.
    @virafb, let me know if you still want to take item 2: map validation failures to field labels and editor-facing messages.
    @danielmlr, please go ahead with item 3: surface field constraints in the editor.

    Please let me know if either of you won't have time. These are 1.0 blockers, so otherwise somebody else will handle them.

  5. CacheMeOwside commented on Sep 7, 2026

    @CacheMeOwside
    Contributor

    @virafb 👋
    This (2) is still yours if you want it. I'm offering to pick it up only because it's on the 1.0 milestone (and a blocker). If you're already partway in, I'll leave it alone.

  6. danielmlr commented on Sep 12, 2026

    @danielmlr
    Contributor

    @CacheMeOwside for context: virafb can't sign the CLA (they said so when closing #2770 on 29 August), so a PR for item 2 from them couldn't be merged.

  7. danielmlr commented on Sep 15, 2026

    @danielmlr
    Contributor

    Hi @CacheMeOwside 👋 Are you still up for item 2? If your plate's full right now, I'd be happy to pick it up.

    It would take #2426 along: core returns the per-field issues with their codes, and the admin turns them into localized messages that name the field by its label.

  8. danielmlr commented on Sep 19, 2026

    @danielmlr
    Contributor

    Hi @CacheMeOwside 👋 Thanks for offering to take item 2. Since it's a 1.0 blocker, I went ahead and opened #3205 for it, which also closes #2426. If you'd already started on it, just let me know.

  9. ascorbic commented on Sep 21, 2026

    @ascorbic
    Member

    I think all parts of this are now fixed. Thanks all!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions