Skip to content

spec(AST-086): a status is replaced by its owner's next status, not joined - #9070

Merged
cixzhang merged 7 commits into
meta:mainfrom
vjeux:spec/ast-086-fr9-status-replaced
Oct 31, 2026
Merged

cixzhang merged 7 commits into
meta:mainfrom
vjeux:spec/ast-086-fr9-status-replaced

Conversation

@vjeux

@vjeux vjeux commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Exact decision needed

Whether spec:AST-086 FR9's shared channel joins a component's superseded message about its current state with its next one, and the optional public useAnnounce option that asks for replacement. Today every message written to a region before the previous one is exposed is joined, so a component that states its current state on every input, such as a search's result count, has all its stale states spoken together when input outpaces a frame (a barcode scanner, dictation, automation): 1 result Create "Urg" Create "Urge" Create "Urgen" Create "Urgent".

Existing authority searched

Decision

A useAnnounce instance created with hasReplace states one current state of its owner, such as a search's result count: each message it writes withdraws that instance's previous message while it is not yet exposed, whatever their urgencies, and is spoken in the region of its own urgency; a message already exposed has been heard and stays. Messages from other instances, and from instances without hasReplace, still join. useAnnounce takes an optional options object with one field, hasReplace (default false), and the announce(message, politeness) it returns is unchanged. FR14's modal containers withdraw the same way, and a picker's own empty-panel message comes from its search's hasReplace instance (FR13). Astryx's search counts and MultiSelector's create row use a separate useAnnounce({hasReplace: true}) instance (spec:AST-075 FR7, Tokenizer's and MultiSelector's records); selection messages keep their own plain instance. DEC-4 records it; its decider is the approving engineering owner, with INV8 and FR8 as its reasons.

Public option (spec:AST-002 FR5, FR20): two otherwise identical messages, "7 results" written twice in one frame, need different outcomes. As a search's count it replaces the earlier one; as two distinct notices both are spoken. Only the caller knows which it sends, and the channel cannot derive it. Before: const announce = useAnnounce(); announce(t('resultCount', {count}));. After: const announceCount = useAnnounce({hasReplace: true}); announceCount(t('resultCount', {count}));. Existing calls are unchanged.

Rejected alternatives

  • Each owner debouncing its own messages: every announcement waits, and every owner repeats the rule.
  • An exception for every useAnnounce message: Astryx's comboboxes and Tokenizer announce through useAnnounce, so their counts would keep joining.
  • A string key on each message (announce(message, politeness, {currentState})): a third argument whose strings every call must keep consistent, where one instance already states one state.

Claim scope and non-goals

FR9's replacement within one hasReplace instance, the option that asks for it, and the AST-075, Tokenizer and MultiSelector sentences that describe their counts' announcement. Which messages a component sends through a hasReplace instance stays with its own record.

Evidence and implementation relationship

  • On main, the MultiSelector CreateRow accessibility spec, which types into the create row at about four keystrokes per frame, fails 9 of 10 runs alone and 26 of 48 under load, its polite region reading the joined statuses.
  • fix(MultiSelector): a search status the next keystroke replaces is withdrawn, not joined #8640 implements the replacement for MultiSelector. With it, the spec passes 10 of 10 alone and 48 of 48 under load. It lands after this record changes, adopting useAnnounce({hasReplace: true}).

Writing rubric results

  • R1 narration: none, in AST-086, AST-075, Tokenizer's or MultiSelector's record.
  • R2 research in the record: none.
  • R3 evidence naming a change: none.
  • R4 rejected alternatives over one line or outside a decision: one, inside DEC-4, one line; nothing added to the other records.
  • R5 pull request or issue references remaining: none.
  • R6 ruled open questions still listing options: none.

Scope

  • This PR contains specification records only.
  • It records observable behavior and preserves equivalent internal implementations.
  • Any named internal mechanism is intentionally public, names its dependent caller/system, and explains why an equivalent implementation would not satisfy the contract.
  • Verification states the evidence layer and failure signal without prescribing CI job/workflow topology.
  • It records one intentional durable decision, not a rescue for a separable tagalong.
  • The canonical knowledge owner, affected current claims, and overlapping open work are named; any ownership collision is resolved or called out for human decision.
  • Adjacent API, visual, accessibility, composition, and implementation facts remain outside scope unless this exact decision depends on them.
  • Public text and artifacts contain no internal Meta context.

Validation

Local gates at 2b84901: pnpm check:knowledge (records and templates aligned), Prettier, git diff --check, pnpm check:repo.

…oined

FR9 joins every message written to a region before the previous one is
exposed, so one owner's statuses written within one frame are all spoken:
a search that states its result count on each keystroke says `1 result
Create "Urg" Create "Urge" ...` when input outpaces a frame, as a barcode
scanner, dictation or automation types. FR9 now replaces a status, a
message that states its owner's current state, by its owner's next status:
the previous one is withdrawn while it is not yet exposed. Messages from
different owners still join, which is what the join is for.
@vercel

vercel Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
astryx Ready Ready Preview Oct 31, 2026 1:23am UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 30, 2026
@astracat-bot

astracat-bot Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Oct 31, 2026, 1:24 AM UTC

@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Oct 30, 2026
vjeux added 2 commits October 30, 2026 16:04
…d the modal containers

The record's "status message" covers every message, so the replaced kind is
a current-state message: one that states its owner's current state and is
superseded by the owner's next one. FR9 says the owner is the announcing
component instance and that messages sent with the public `useAnnounce` keep
joining. FR13 makes a picker's empty-panel message its search's
current-state message, so the empty panel and the search's count replace
each other instead of joining. FR14's modal containers replace the same way.
The FR9 and FR14 verification rows check the replacement and no longer fail
a superseded message by design; Current-state impact names the owners that
change, and DEC-4 records the decision.
Current records already decide that an owner's superseded current-state
message is not spoken: `architecture:interaction-modality` INV8 (superseded
work must not change the current status or announcement) and FR8's dropped
busy start. DEC-4 now states that basis and names its recorder, instead of
crediting a decider.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This pull request changes a current requirement: spec:AST-086 FR9 says messages written before the previous one is exposed are joined and "none is dropped", and FR14 applies the same join inside modals. Approval therefore waits on an engineering owner deciding the change. Any engineering owner, the author included if they are one, records that decision by commenting /approve-spec <full head SHA> on the final head. The decision to settle is whether a component's pending current-state message (a result count, a create row or a picker's empty-panel message) is withdrawn by its next one, while other components' and useAnnounce messages keep joining. Two fixes are needed whatever the ruling:

  • The useAnnounce exception conflicts with spec:AST-075 DEC-2 and Tokenizer's record, which send Astryx's own result counts through useAnnounce. Either amend them in the same decision or give useAnnounce the same replacement.
  • DEC-4 says INV8 and FR8 already decide this rule, but INV8 leaves supersession to component contracts and FR8 covers busy starts only. Name the approving owner as Decider and keep INV8 and FR8 as the reason.

[Automated review]

exposed has been heard and stays as it is. The owner is the announcing
component instance (FR1), so two pickers on one page each keep their own,
and messages from different owners still join. Messages sent with the
public `useAnnounce` keep joining: the channel cannot tell which of an

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

spec:AST-075 DEC-2 says Astryx's own comboboxes announce their result count with useAnnounce, the same hook products use, and Tokenizer's record says its sheet's count and empty-state text go through useAnnounce. If useAnnounce messages keep joining, either those counts keep joining (which contradicts the Impact entry at lines 312-320) or those records need amending in this decision. Alternatively, useAnnounce gets a way to mark a current-state message.

**Reference:** `spec:AST-086/DEC-4`
**Decider:** vjeux (recorder), 2026-10-30

Decided by current authority: `architecture:interaction-modality` INV8

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

INV8 leaves supersession to component and family contracts, and its Restorative-change routing says it authorizes no new public delta. FR8 covers busy starts only, and FR9 currently says "none is dropped". This is an owner decision rather than a consequence of current authority, so the Decider line should name the approving owner, with INV8 and FR8 kept as the reason.

except a current-state message its owner replaces. A current-state message
states its owner's current state and is superseded by the owner's next one,
such as a search's result count or create row; FR8 governs busy starts. It
is replaced by its owner's next current-state message, not joined: when the

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As written, a component has one replacement slot. MultiSelector announces its selection count and its result count from the same instance, so a pending "2 of 5 selected" would be withdrawn by a result count in the same frame, although the count does not supersede it. Consider scoping the replacement to the next message about the same state, as the FR13 clause does with "its search's".

… per state

Astryx's comboboxes compose `useAnnounce` for their result count, the same
hook products use (`spec:AST-075` DEC-2), and Tokenizer's sheet sends its
count and empty-state text through it, so an exception for `useAnnounce`
would have left those counts joining. `useAnnounce` now takes one optional
field, `currentState`, in an options argument after the urgency: a message
marked with it replaces its owner's previous message about the same state,
and a message without it keeps joining. Replacement is scoped to the same
state, so one picker's selection count is not withdrawn by its result
count. AST-075 FR7 and Tokenizer's record mark their counts. DEC-4's
decider is pending the approving engineering owner, with INV8 and FR8 as
its reasons.
…ce instance; a newer marked message wins across urgencies

FR9 says what `currentState` takes: a string naming the state, compared by
equality within one `useAnnounce` instance, so two marked messages are about
the same state when that instance sends both with equal strings. A newer
marked message replaces an older one about the same state whatever their
urgencies: a polite message followed by an assertive one withdraws the
polite one while it is not yet exposed, and the newer is spoken in the
region of its own urgency. FR14's containers replace the same way across
their two containers, the impact line names the type, and the FR9
verification row covers both.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approval waits on an engineering owner's decision: this pull request intentionally changes spec:AST-086 FR9 and FR14, which today join every message written before the previous one is exposed ("none is dropped"), and adds an optional currentState field to the public useAnnounce. An engineering owner records that decision by commenting /approve-spec <full head SHA> on the final head; the author may do so if they are an engineering owner. Independently of that decision, these record fixes are needed:

  • FR9 defines the owner as "the component instance that sends it", which useAnnounce cannot observe; define it as the useAnnounce call that returned the announce, and scope FR13's own-content sentence to that same owner.
  • State the type of currentState, for example a string the owner chooses that only replaces the same owner's message with the same string.
  • Reword the Intent's "hears each status change once" so it describes the current status.

[Automated review]

exposed.
one is exposed are joined in order into one announcement; none is dropped,
except a current-state message its owner replaces. A current-state message
states one current state of its owner, the component instance that sends it

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useAnnounce cannot see which component it runs in: each call returns its own announce. As written, a component that renders two comboboxes, each with its own useAnnounce() and {currentState: 'resultCount'}, is one owner announcing one state, so one count must withdraw the other's, which the hook cannot do. Defining the owner of a useAnnounce message as the call that returned its announce matches "two pickers … do not replace each other" and what an implementation can key on.

replace each other, and every other message still joins. `useAnnounce` offers
the mark to every caller, Astryx's components and apps alike (`spec:AST-075`
DEC-2), as one optional field, `currentState`, in an options argument after
the urgency: `announce(message, 'polite', {currentState: 'resultCount'})`. A

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The record shows one value, 'resultCount', but not the field's type. Please state whether currentState takes any string the owner chooses (and that only the same owner's message with the same string replaces it), so a product can tell whether {currentState: 'fromCount'} is valid (spec:AST-002 FR16, FR20).

element is hidden, and leaves nothing behind after unmount. Action
announcements, such as a result count from a query handler or FR8 busy
starts, keep their own owner. Content revealed without a re-render of its
starts, keep their own owner. A message a component announces as its own

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sentence covers any component's own-content message that states the next state of "an owner's" message. An Astryx EmptyState inside a product's own combobox would then have to replace the product's pending count, but nothing links EmptyState to the product's announce. Scoping it to an empty-panel message from the same owner keeps the promise one the channel can keep.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approval waits on an engineering owner's decision: this pull request intentionally changes spec:AST-086 FR9 and FR14, which today join every message written before the previous one is exposed ("none is dropped"), adds an optional public currentState field to useAnnounce, and lets a newer marked message replace an older one across urgencies. An engineering owner records that decision by commenting /approve-spec <full head SHA> on the final head; the author may do so if they are an engineering owner. Independently of that decision, these record fixes are needed:

  • Scope FR13's own-content sentence to an empty-panel message the owner renders for its own search, since nothing links another component's message, such as an EmptyState inside a product's combobox, to the owner's announce.
  • Add the modal cases this head introduces to the FR14 Verification row: a marked polite message replaced by a marked assertive one, and a count replaced by the empty-panel message.
  • Define FR9's owner as the useAnnounce instance rather than the component instance, and reword the Intent's "hears each status change once".

[Automated review]

| FR8 | Shared test: no live role inside a busy owner; channel spy on the owner | Busy TextInput, Selector, Switch thumb, ChatMessageList loader, busy CommandPalette (region inside its dialog), operation that settles before its busy announcement is exposed | A composed indicator with a live role or its own announcement, or a busy owner that announces nothing, fails, or a busy start announced after its outcome, fails |
| FR9 | `useAnnounce` tests | First use, many components, region removed, server render, two messages in one frame, clear after a joined message, the same message twice in one frame, one owner's current-state messages written in one frame, two pickers' result counts in one frame, a result count followed by its picker's empty-panel message and the reverse, a current-state message already exposed when the next is written, unmarked `useAnnounce` messages in one frame, one picker's marked selection and result counts in one frame, a marked polite message followed by a marked assertive one about the same state, equal `currentState` strings from two `useAnnounce` instances | A second document-level region pair, stale text after the clear delay, a dropped same-frame message from another owner, about another state, or unmarked, a superseded current-state message spoken with its owner's next one, a polite message about a state still spoken after an assertive one about it, the newer spoken at the older's urgency, two `useAnnounce` instances' equal strings replacing each other, an exposed current-state message taken back, or a joined message surviving a clear fails, or an identical same-frame repeat announced twice, fails |
| FR10 | Accessibility-tree check with a Dialog open | Info and error toast shown over an open Dialog | A toast that reaches only the body-level channel, or no exposed live container, fails |
| FR14 | Hook tests and an accessibility-tree check with an Astryx modal open | Owner outside any modal, inside an open non-modal dialog, inside an open modal (polite and assertive, repeat of the same words, empty message), busy start inside a modal, busy start inside a native modal an app renders without Astryx, alone and opened inside an Astryx Dialog; a modal sheet's row toggle in a real engine, app code calling `useAnnounce` inside a Dialog, CommandPalette result count and Lightbox position message while each is open, a message followed by closing its Dialog before it is exposed (inside another open modal and on the page), a message pending when a CommandPalette opened over a Dialog closes, app code calling `useAnnounce` outside the Dialog it renders (body-level channel); a search's result counts written in one frame inside a modal sheet | A channel message from inside an open modal that reaches only the body-level channel, an FR14 container mounted holding its text, a second FR14 container of one urgency in one dialog, an FR14 container the engine's accessibility tree omits, a dropped message of two from different owners written before the first is exposed, a superseded current-state message spoken from an FR14 container, an empty message that leaves text in the modal's FR14 container, an app `useAnnounce` message from inside an Astryx modal that reaches only the body-level channel, a CommandPalette or Lightbox message about its open modal that reaches only the body-level channel, a message lost when its modal closes before it is exposed, or a pending message handed to a channel still inert after the close, a busy start inside an app-rendered native modal that reaches only the body-level channel, or one inside an app-rendered native modal nested in an Astryx Dialog that is delivered to the Dialog's FR14 container fails |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FR14 now replaces "in either container" and speaks the newer message "in the container of its own urgency" (lines 199-202), and Tokenizer's sheet, a modal, makes its count and empty-state text one current-state message. This row only adds "a search's result counts written in one frame inside a modal sheet". The modal containers have their own writer, so please add a marked polite message followed by a marked assertive one about the same state, and a count followed by the empty-panel message, both inside a modal, with their failures.

…ending message

The mark moves from a string key on each message to the instance:
`useAnnounce` takes an optional options object with one field,
`hasReplace` (default false), and the `announce(message, politeness)` it
returns is unchanged. A `useAnnounce` instance created with `hasReplace`
states one current state of its owner, such as a search's result count:
each message it writes withdraws that instance's previous message while it
is not yet exposed, whatever their urgencies, and is spoken in the region
of its own urgency; a message already exposed has been heard and stays.
Messages from other instances, and from instances without `hasReplace`,
still join. FR14's containers withdraw the same way, and FR13's picker
empty-panel message comes from its search's `hasReplace` instance.

AST-075 FR7, Tokenizer's sheet and MultiSelector's record send the search's
counts (and MultiSelector's create row) through a separate
`useAnnounce({hasReplace: true})` instance; selection keeps its own plain
one. DEC-4's decider stays pending, and the verification rows follow.
@cixzhang

Copy link
Copy Markdown
Contributor

/approve-spec 26ab4b2

@github-actions github-actions Bot removed the needs:spec-owner-review Current knowledge records await owner approval label Oct 31, 2026
@cixzhang cixzhang added the ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) label Oct 31, 2026
@cixzhang
cixzhang marked this pull request as ready for review October 31, 2026 00:38

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An engineering owner has approved the direction of this change; two fixes to the records are still needed before it lands.

  • Scope AST-086 FR13's new sentence to a picker's own empty-panel message: as written it also binds an Astryx EmptyState that a product renders beside its own useCombobox field, and EmptyState has no way to send through the product's hasReplace instance.
  • Add the modal cases this head introduces to the FR14 Verification row: a polite then an assertive message from one hasReplace instance, and a result count followed by the empty-panel message, both inside a modal.

Editing the record text needs a fresh /approve-spec from an engineering owner on the final head.

[Automated review]

Comment on lines +146 to +150
starts, keep their own owner. An own-content message (FR11) that states the
next value of a state a `hasReplace` instance states comes from that instance
(FR9): a picker's empty-panel message comes from its search's `hasReplace`
instance, so it replaces the search's result count not yet exposed, and the
search's next count replaces it while it is not yet exposed. Content revealed

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sentence covers every own-content message that states the next value of a hasReplace instance's state, not only a picker's own panel. A product that renders an Astryx EmptyState beside its own useCombobox field (AST-075 FR7 gives that field its own hasReplace instance for its count and for "none") would need EmptyState's message to come from the product's instance, and EmptyState has no prop that reaches it. Could this be scoped to the owner's own message, for example "A picker's own empty-panel message (FR11) comes from its search's hasReplace instance (FR9), so it replaces …"?

| FR8 | Shared test: no live role inside a busy owner; channel spy on the owner | Busy TextInput, Selector, Switch thumb, ChatMessageList loader, busy CommandPalette (region inside its dialog), operation that settles before its busy announcement is exposed | A composed indicator with a live role or its own announcement, or a busy owner that announces nothing, fails, or a busy start announced after its outcome, fails |
| FR9 | `useAnnounce` tests | First use, many components, region removed, server render, two messages in one frame, clear after a joined message, the same message twice in one frame, a `hasReplace` instance's messages written in one frame, two pickers' result counts in one frame, a result count followed by its picker's empty-panel message and the reverse, a `hasReplace` message already exposed when the next is written, messages from instances without `hasReplace` in one frame, one picker's result count and selection message in one frame (separate instances), a polite message followed by an assertive one from one `hasReplace` instance | A second document-level region pair, stale text after the clear delay, a dropped same-frame message from another instance or from an instance without `hasReplace`, a `hasReplace` instance's superseded message spoken with its next one, its polite message still spoken after its assertive one, the newer spoken at the older's urgency, an exposed message taken back, or a joined message surviving a clear fails, or an identical same-frame repeat announced twice, fails |
| FR10 | Accessibility-tree check with a Dialog open | Info and error toast shown over an open Dialog | A toast that reaches only the body-level channel, or no exposed live container, fails |
| FR14 | Hook tests and an accessibility-tree check with an Astryx modal open | Owner outside any modal, inside an open non-modal dialog, inside an open modal (polite and assertive, repeat of the same words, empty message), busy start inside a modal, busy start inside a native modal an app renders without Astryx, alone and opened inside an Astryx Dialog; a modal sheet's row toggle in a real engine, app code calling `useAnnounce` inside a Dialog, CommandPalette result count and Lightbox position message while each is open, a message followed by closing its Dialog before it is exposed (inside another open modal and on the page), a message pending when a CommandPalette opened over a Dialog closes, app code calling `useAnnounce` outside the Dialog it renders (body-level channel); a search's result counts written in one frame inside a modal sheet | A channel message from inside an open modal that reaches only the body-level channel, an FR14 container mounted holding its text, a second FR14 container of one urgency in one dialog, an FR14 container the engine's accessibility tree omits, a dropped message of two from different instances written before the first is exposed, a `hasReplace` instance's superseded message spoken from an FR14 container, an empty message that leaves text in the modal's FR14 container, an app `useAnnounce` message from inside an Astryx modal that reaches only the body-level channel, a CommandPalette or Lightbox message about its open modal that reaches only the body-level channel, a message lost when its modal closes before it is exposed, or a pending message handed to a channel still inert after the close, a busy start inside an app-rendered native modal that reaches only the body-level channel, or one inside an app-rendered native modal nested in an Astryx Dialog that is delivered to the Dialog's FR14 container fails |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FR14 now withdraws "in either container", but this row's new representative state is only a search's counts in one frame inside a modal sheet. Please add a polite message followed by an assertive one from one hasReplace instance inside a modal, and a result count followed by its picker's empty-panel message inside a modal sheet (Tokenizer's case). The modal containers are written by their own code path, so the FR9 row's page-channel states do not cover them.

…row covers the modal cases

FR13's sentence bound every own-content message that states the next value
of a `hasReplace` instance's state, so an Astryx `EmptyState` beside a
product's own `useCombobox` field would have had to send through the
product's instance, which it cannot reach. It now binds a picker's own
empty-panel message. FR14's verification row gains the modal cases FR14
introduces: a polite then an assertive message from one `hasReplace`
instance inside a modal, and a result count followed by its picker's
empty-panel message inside a modal sheet.
@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Oct 31, 2026
@cixzhang

Copy link
Copy Markdown
Contributor

/approve-spec 2b84901

@github-actions github-actions Bot removed the needs:spec-owner-review Current knowledge records await owner approval label Oct 31, 2026

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The records are ready to land: an engineering owner approved this head, and both fixes from the last review are in, with FR13 now covering only a picker's own empty-panel message and the FR14 Verification row naming the modal cases. Two optional wording tweaks can wait for a later edit:

  • The AST-086 impact entry credits FR9, FR13 and FR14 for PowerSearch's adoption, but PowerSearch's count is its caller's resultCount, which no requirement routes through a hasReplace instance.
  • The Intent's "hears each status change once" could read "hears each current status once" to match DEC-4.

[Automated review]

@cixzhang
cixzhang merged commit 94a2a8e into meta:main Oct 31, 2026
51 checks passed
@vjeux
vjeux deleted the spec/ast-086-fr9-status-replaced branch October 31, 2026 01:31
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 31, 2026
…thdrawn, not joined

`spec:AST-086` FR9 joins messages written to a region before the previous
one is exposed, so MultiSelector, which states its search's status on
every keystroke, said every superseded status when input outpaced a frame,
as a barcode scanner, dictation or automation types: `1 result Create
"Urg" Create "Urge" ...`.

meta#9070 records the fix. `useAnnounce` takes an optional `{hasReplace}`: an
instance created with it withdraws its previous message while it is not
yet exposed, whatever their urgencies, and the newer is spoken in the
region of its own urgency; messages from other instances, and from
instances without it, still join. Each write now tells its announcer how
to take it back, wherever the route put it: the shared region, a modal's
container, words held for an exiting modal, or a message waiting for its
modal to be presented. `useAnnounceRenderedText` can speak through an
owner's announcer. MultiSelector sends its result count, its create row
and its empty-panel message (FR13) through one `hasReplace` instance;
selection changes keep their own.

The CreateRow spec reads each search status whole again instead of
accepting the joined text.

Red first: on main, four statuses in one frame were all said (now the
last); a count then an empty panel, and an empty panel then a count, said
both (now the newer), on the page and inside a Dialog; a `hasReplace`
instance's messages joined in one frame, polite then assertive, inside a
modal and in the commit that presents one; and the tightened CreateRow
spec failed 6 of 10 runs alone and 35 of 48 at 8 workers.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 31, 2026
…thdrawn, not joined

`spec:AST-086` FR9 joins messages written to a region before the previous
one is exposed, so MultiSelector, which states its search's status on
every keystroke, said every superseded status when input outpaced a frame,
as a barcode scanner, dictation or automation types: `1 result Create
"Urg" Create "Urge" ...`.

meta#9070 records the fix. `useAnnounce` takes an optional `{hasReplace}`: an
instance created with it withdraws its previous message while it is not
yet exposed, whatever their urgencies, and the newer is spoken in the
region of its own urgency; messages from other instances, and from
instances without it, still join. Each write now tells its announcer how
to take it back, wherever the route put it: the shared region, a modal's
container, words held for an exiting modal, or a message waiting for its
modal to be presented, and only while those words still wait there: a
handle never takes back equal words another announcer wrote or held
after its own were exposed or delivered, and words held for an exiting
modal count their announcers. `useAnnounceRenderedText` can speak through an
owner's announcer. MultiSelector sends its result count, its create row
and its empty-panel message (FR13) through one `hasReplace` instance;
selection changes keep their own.

The CreateRow spec reads each search status whole again instead of
accepting the joined text.

Red first: on main, four statuses in one frame were all said (now the
last); a count then an empty panel, and an empty panel then a count, said
both (now the newer), on the page and inside a Dialog; a `hasReplace`
instance's messages joined in one frame, polite then assertive, inside a
modal and in the commit that presents one; inside a modal, a count's
handle took back another announcer's equal words written after its own
were spoken, and every holder's share of words held through an exit;
and the tightened CreateRow
spec failed 6 of 10 runs alone and 35 of 48 at 8 workers.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 31, 2026
…thdrawn, not joined

`spec:AST-086` FR9 joins messages written to a region before the previous
one is exposed, so MultiSelector, which states its search's status on
every keystroke, said every superseded status when input outpaced a frame,
as a barcode scanner, dictation or automation types: `1 result Create
"Urg" Create "Urge" ...`.

meta#9070 records the fix. `useAnnounce` takes an optional `{hasReplace}`: an
instance created with it withdraws its previous message while it is not
yet exposed, whatever their urgencies, and the newer is spoken in the
region of its own urgency; messages from other instances, and from
instances without it, still join.

Each write gives its announcer a receipt kept beside the words: in the
shared region, a modal's container, words held for an exiting modal, or a
message waiting for its modal to be presented. When a modal hands its
waiting words on (it leaves the accessibility tree, exits, closes, a step
is left, or its write frame finds it unexposed), they move one delivery
per announcer, and each receipt follows its words. A receipt takes back
only its own share and only while the words wait, so it never reaches
equal words another announcer wrote after its own were exposed. The
busy-announcement hook takes its start back through the same receipt.

`useAnnounceRenderedText` can speak through an owner's announcer.
MultiSelector sends its result count, its create row and its empty-panel
message (FR13) through one `hasReplace` instance; selection changes keep
their own. The CreateRow spec reads each search status whole again
instead of accepting the joined text.

Red first: on main, four statuses in one frame were all said (now the
last); a count then an empty panel, and an empty panel then a count, said
both (now the newer), on the page and inside a Dialog; a `hasReplace`
instance's messages joined in one frame, polite then assertive, inside a
modal and in the commit that presents one, and when its modal handed its
first message on before it was exposed; and the tightened CreateRow spec
failed 6 of 10 runs alone and 35 of 48 at 8 workers.
cixzhang pushed a commit that referenced this pull request Oct 31, 2026
…thdrawn, not joined (#8640)

## Why

When input outpaces a frame (a barcode scanner, dictation, automation), a screen-reader user hears MultiSelector's stale statuses joined together: `1 result Create "Urg" Create "Urge" Create "Urgen" Create "Urgent"`.

## What led to it

Found while checking the text-entry components' accessibility tests (part of bringing Astryx's touch behavior to parity with iOS's UIKit, text entry first). #8244 made the announcer join every message written before the previous one is read out (`spec:AST-086` FR9), so two components speaking in the same frame are both heard. MultiSelector states its search's status on every keystroke, so fast typing joins statuses that no longer apply. Its CreateRow accessibility spec failed on that, and now accepts the joined text.

## The record

#9070, approved by the spec owner, amends `spec:AST-086` FR9, FR13 and FR14: a `useAnnounce` instance created with `hasReplace` withdraws its own previous message while it is not yet exposed, whatever their urgencies, and the newer is spoken in the region of its own urgency; messages from other instances still join. This pull request implements it and lands after #9070, so its public option is settled by that record, not decided here (`spec:AST-002` FR19).

## Options and tradeoffs

- Debounce MultiSelector's status: delays every announcement, and every component would repeat it.
- Keep the joined text: scanner and dictation users keep hearing stale counts.
- Replace per announcer instance, in the shared announcer: fixes it once for every caller, and different instances still join.

## Why this choice

It is the shape #9070 records. `useAnnounce({hasReplace: true})` is one optional field; the `announce(message, politeness)` it returns is unchanged. Each write gives its announcer a receipt kept beside the words, wherever the route put them: the shared region, a modal's container, words held for an exiting modal, or a message waiting for its modal to be presented. When a modal hands its waiting words on (it leaves the accessibility tree, exits, closes, a step is left, or its write frame finds it unexposed), they move one delivery per announcer and each receipt follows its words. A receipt takes back only its own share and only while the words wait, so it never reaches equal words another announcer wrote after its own were spoken. The busy-announcement hook takes its start back through the same receipt. MultiSelector sends its result count, its create row and its empty-panel message through one such instance; selection changes keep their own. Selector, Typeahead and the others adopt it separately.

## How to check it

1. Run `MultiSelector/CreateRow.a11y.chromium.spec.ts` in Chromium. It types into the create row at about four keystrokes per frame. It now reads the two search statuses exactly, in place of the `newestMessage` regex; the creation message, which comes from another announcer and can still join, keeps the regex. Expected: on main it fails on the joined statuses; with this change the polite region reads `1 result`, then `Create "Urgent"`.
2. With a screen reader, type into MultiSelector at a normal pace. Expected: each status is read as before.

## Evidence

- CreateRow spec, tightened as above: on main 560c666 it failed 6 of 10 runs alone and 35 of 48 at 8 workers, reading `Create "Urge" Create "Urgen" Create "Urgent"`; with this change it passes 10 of 10 alone and 48 of 48 at 8 workers.
- Unit tests that fail on main first: four statuses in one frame say the last; a count then an empty panel, and an empty panel then a count, say the newer; the same inside a Dialog's container; a `hasReplace` instance's messages in one frame, polite then assertive, inside a modal and in the commit that presents one; and its first message, handed on by its modal before it was exposed (the Dialog leaving the accessibility tree and coming back, a sheet's exit and close, the Dialog closing, a step left for the next one, or its write frame finding it unexposed), is taken back by its next. Guards that pass on main and with the change: other instances and plain instances still join, a shared share stays, an exposed message stays, a selection change and a count in one frame are both said, and inside a modal an equal message another announcer writes after the count's was spoken, and another announcer's share of words held while a sheet exits, both stay.
- Local gates at 4e89f16 (one commit on main bb18c81): build, typecheck, repo checks and Prettier pass; the core UI suite passes (16,616 tests); the node suite passes except the two `cache.test.mjs` lock tests that fail on this machine without the change too (#8520).

This branch was successfully deployed

1 active deployment
Preview — 2b849017 Deployed Oct 31, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants