fix: the result preview and the robots.txt tester survive a dropped first action (CR-04)

Both elements sent their first summer-action from connectedCallback and
kept it in flight forever when the host dropped it (the v0.5.1 widget host
appends the element before it keeps a reference). A shared ActionQueue no
longer trusts that an emitted action was taken: one the host did not mark
busy synchronously is retried once on the next task, a second drop shows
an error, and a disconnect forgets the request in flight. The first
request is sent on a microtask. Element tests drive a fake host that
mirrors WidgetField's contract, including the append-first ordering, and
the robots.txt tester gets its own tests.
This commit is contained in:
2026-10-27 15:24:28 +01:00
parent 2d81c261b5
commit 3ecbd1f711
8 changed files with 529 additions and 52 deletions

View File

@@ -0,0 +1,101 @@
import { emit } from './labels'
/**
* Sends a widget element's summer-action payloads one at a time.
*
* The admin's widget host takes an action by setting the element's `busy`
* attribute synchronously, inside the summer-action listener, and removes
* it when the request settles (after it dispatched summer-result, or set
* `state=error`). A payload sent while a request runs is queued (one at
* most, the newest wins) and sent when `busy` goes.
*
* The queue does not trust that an emitted action was taken: when `busy`
* is still absent after the event returns, the host dropped it (for
* example an action sent from connectedCallback before the host wired the
* element). The payload is then retried once on the next task; a second
* drop is reported through onDropped, so the element shows an error rather
* than waiting forever.
*/
export class ActionQueue<P> {
private inflight: P | null = null
private queued: P | null = null
private retry: P | null = null
private retried = false
private timer: ReturnType<typeof setTimeout> | undefined
constructor(
private readonly host: HTMLElement,
private readonly onDropped: (payload: P) => void,
) {}
/** The payload the host is working on, null when none. */
get current(): P | null {
return this.inflight
}
/** Sends payload, or queues it while a request runs. */
request(payload: P): void {
if (this.host.hasAttribute('busy') || this.inflight !== null) {
this.queued = payload
return
}
this.send(payload)
}
/** Clears the request that just settled (summer-result or state=error) and sends what waits. */
settle(): P | null {
const done = this.inflight
this.inflight = null
this.flush()
return done
}
/** Sends the waiting payload when the host is free. */
flush(): void {
if (this.host.hasAttribute('busy') || this.inflight !== null) {
return
}
const next = this.retry ?? this.queued
if (next === null) {
return
}
if (this.retry !== null) {
this.retry = null
} else {
this.queued = null
}
this.send(next)
}
/** Forgets the request in flight and any pending retry (the element left the page). */
reset(): void {
clearTimeout(this.timer)
this.timer = undefined
this.inflight = null
this.retry = null
this.retried = false
}
private send(payload: P): void {
this.inflight = payload
emit(this.host, 'summer-action', { payload })
if (this.host.hasAttribute('busy') || this.inflight !== payload) {
// Taken (or already answered synchronously).
this.retried = false
return
}
this.inflight = null
if (this.retried) {
this.retried = false
this.onDropped(payload)
return
}
this.retried = true
this.retry = payload
clearTimeout(this.timer)
this.timer = setTimeout(() => {
this.timer = undefined
this.flush()
}, 0)
}
}

View File

@@ -100,6 +100,7 @@ export interface Labels {
unsaved: string
stagingNote: string
urlRequired: string
testFailed: string
// E6
exportHeading: string
importHeading: string
@@ -217,6 +218,7 @@ const en: Labels = {
unsaved: 'Tests the text in the editor and the AI bot switches, including unsaved changes.',
stagingNote: 'On this environment every address is blocked. The result shows the production behaviour.',
urlRequired: 'Enter an address starting with /.',
testFailed: 'The test could not be run. Try again.',
exportHeading: 'Export',
importHeading: 'Import',
dataset: 'Data',
@@ -343,6 +345,7 @@ const pl: Labels = {
unsaved: 'Sprawdza tekst w edytorze i przełączniki botów AI, także niezapisane zmiany.',
stagingNote: 'W tym środowisku każdy adres jest zablokowany. Wynik pokazuje zachowanie na produkcji.',
urlRequired: 'Wpisz adres zaczynający się od /.',
testFailed: 'Nie udało się uruchomić testu. Spróbuj ponownie.',
exportHeading: 'Eksport',
importHeading: 'Import',
dataset: 'Dane',

View File

@@ -1,4 +1,5 @@
import { el, emit, fmt, icon, jsonAttr, labelsFor, uid, uiLocaleOf, type Labels } from './labels'
import { ActionQueue } from './actions'
import { el, fmt, icon, jsonAttr, labelsFor, uid, uiLocaleOf, type Labels } from './labels'
export const ROBOTSTESTER_TAG = 'golem15-seo-robotstester'
@@ -49,8 +50,7 @@ export class SeoRobotsTesterElement extends HTMLElement {
private context: ContextData | null = null
private result: TestData | null = null
private error = ''
private inflight: Payload | null = null
private queued: Payload | null = null
private readonly actions = new ActionQueue<Payload>(this, (payload) => this.failed(payload))
private connected = false
private input: HTMLInputElement | null = null
private select: HTMLSelectElement | null = null
@@ -62,7 +62,7 @@ export class SeoRobotsTesterElement extends HTMLElement {
private readonly onResult = (ev: Event): void => {
const detail = (ev as CustomEvent).detail as { data?: unknown } | undefined
const data = detail?.data as ContextData | TestData | undefined
this.inflight = null
this.actions.settle()
if (data && data.op === 'context') {
this.context = data
this.build(true)
@@ -71,19 +71,25 @@ export class SeoRobotsTesterElement extends HTMLElement {
this.error = ''
this.renderResult()
}
this.flush()
}
connectedCallback(): void {
this.connected = true
this.addEventListener('summer-result', this.onResult)
this.build(false)
this.request({ op: 'context' })
// Deferred: the host may wire the element only after connecting it.
queueMicrotask(() => {
if (this.connected && !this.context) {
this.request({ op: 'context' })
}
})
}
disconnectedCallback(): void {
this.connected = false
this.removeEventListener('summer-result', this.onResult)
// A result that arrives while disconnected is lost; start over on reconnect.
this.actions.reset()
}
attributeChangedCallback(name: string, _old: string | null, value: string | null): void {
@@ -91,10 +97,10 @@ export class SeoRobotsTesterElement extends HTMLElement {
return
}
if (name === 'busy' && value === null) {
this.flush()
this.actions.flush()
}
if (name === 'state' && value === 'error') {
this.inflight = null
this.actions.settle()
}
if (name === 'ui-locale') {
this.build(true)
@@ -108,21 +114,16 @@ export class SeoRobotsTesterElement extends HTMLElement {
}
private request(payload: Payload): void {
if (this.hasAttribute('busy') || this.inflight) {
this.queued = payload
return
}
this.inflight = payload
emit(this, 'summer-action', { payload })
this.actions.request(payload)
}
private flush(): void {
if (this.hasAttribute('busy') || this.inflight || !this.queued) {
return
/** A request the host never took: the tester keeps the default crawlers, a test says it failed. */
private failed(payload: Payload): void {
if (payload.op === 'test') {
this.error = this.labels().testFailed
this.result = null
this.renderResult()
}
const next = this.queued
this.queued = null
this.request(next)
}
private build(force: boolean): void {

View File

@@ -1,4 +1,5 @@
import { el, emit, fmt, icon, jsonAttr, labelsFor, plural, uiLocaleOf, type Labels } from './labels'
import { ActionQueue } from './actions'
import { el, fmt, icon, jsonAttr, labelsFor, plural, uiLocaleOf, type Labels } from './labels'
import { DESCRIPTION_FONT, DESCRIPTION_MAX_PX, DESCRIPTION_MOBILE_MAX_PX, TITLE_FONT, TITLE_MAX_PX, truncate } from './measure'
export const SERP_TAG = 'golem15-seo-serp'
@@ -135,8 +136,7 @@ export class SeoSerpElement extends HTMLElement {
private contextFailed = false
private device: 'desktop' | 'mobile' = 'desktop'
private shownLocale = ''
private inflight: Payload | null = null
private queued: Payload | null = null
private readonly actions = new ActionQueue<Payload>(this, (payload) => this.failed(payload))
private duplicates = new Map<string, DuplicatesData>()
private checking = false
private timer: ReturnType<typeof setTimeout> | undefined
@@ -146,7 +146,7 @@ export class SeoSerpElement extends HTMLElement {
private readonly onResult = (ev: Event): void => {
const detail = (ev as CustomEvent).detail as { data?: unknown } | undefined
const data = detail?.data as ContextData | DuplicatesData | undefined
this.inflight = null
this.actions.settle()
if (data && data.op === 'context') {
this.context = data
this.contextFailed = false
@@ -157,7 +157,6 @@ export class SeoSerpElement extends HTMLElement {
this.checking = false
this.render()
}
this.flush()
}
private readonly onLocale = (ev: Event): void => {
@@ -174,7 +173,17 @@ export class SeoSerpElement extends HTMLElement {
this.addEventListener('summer-result', this.onResult)
window.addEventListener('summer:ml-locale', this.onLocale)
this.render()
this.request({ op: 'context' })
// Deferred: the host may wire the element only after connecting it.
queueMicrotask(() => {
if (!this.connected) {
return
}
if (this.context) {
this.scheduleDuplicates(true)
} else {
this.request({ op: 'context' })
}
})
}
disconnectedCallback(): void {
@@ -182,6 +191,10 @@ export class SeoSerpElement extends HTMLElement {
this.removeEventListener('summer-result', this.onResult)
window.removeEventListener('summer:ml-locale', this.onLocale)
clearTimeout(this.timer)
// A result that arrives while disconnected is lost; start over on reconnect.
this.actions.reset()
this.checking = false
this.lastChecked = ''
}
attributeChangedCallback(name: string, _old: string | null, value: string | null): void {
@@ -189,16 +202,17 @@ export class SeoSerpElement extends HTMLElement {
return
}
if (name === 'busy' && value === null) {
this.flush()
this.actions.flush()
return
}
if (name === 'state' && value === 'error') {
if (this.inflight?.op === 'context') {
this.contextFailed = true
const failed = this.actions.settle()
if (failed) {
this.failed(failed)
} else {
this.checking = false
this.render()
}
this.checking = false
this.inflight = null
this.render()
return
}
if (name === 'fill-values') {
@@ -215,21 +229,18 @@ export class SeoSerpElement extends HTMLElement {
/** Sends payload, or queues it (one at most) while a request runs. */
private request(payload: Payload): void {
if (this.hasAttribute('busy') || this.inflight) {
this.queued = payload
return
}
this.inflight = payload
emit(this, 'summer-action', { payload })
this.actions.request(payload)
}
private flush(): void {
if (this.hasAttribute('busy') || this.inflight || !this.queued) {
return
/** A request the host refused (state=error) or never took. */
private failed(payload: Payload): void {
if (payload.op === 'context') {
this.contextFailed = true
} else {
this.lastChecked = ''
}
const next = this.queued
this.queued = null
this.request(next)
this.checking = false
this.render()
}
private fill(): Record<string, unknown> {