cat-tabs and cat-steps collect their children the same way: each item calls register() in onMounted, and the parent renders its header from the list that accumulates. That one decision causes three separate problems.
1. A changed value strands the old entry
deregister runs on unmount with the current props.value, and nothing watches value itself, so changing it leaves the old registration in place and adds a second. Fixed in cat-step-item (#66, found by Copilot review); cat-tab-item still has it.
2. Nothing renders on the server
onMounted never runs during renderToString, so the tablist / progress list is empty in server HTML and appears only on hydration. This is the SEO-relevant one, and it lands harder on tabs than steps — tabs carry page content on www, a stepper is task UI.
#66 shipped a partial mitigation on the steps side: activeValue falls back to the model, so the active panel renders visible rather than every panel shipping behind display: none. The same one-line fallback applies to cat-tabs. The header itself stays client-only either way.
3. Items mount out of order
An item revealed later by v-if registers last and lands at the end of the header regardless of where it sits in the template. cat-steps works around this by comparing panel elements with compareDocumentPosition; cat-tabs does not.
This one is live in production, not hypothetical. calact-network-analysis-tool/app/components/cal/census-details.vue has a conditional tab in the middle of its list:
<cat-tab-item value="geographies" label="Geographies" />
<cat-tab-item value="raw" label="Raw ACS values" />
<cat-tab-item value="coverage" label="Coverage" />
<cat-tab-item v-if="apportionmentSummary" value="apportionment" label="Apportionment" />
<cat-tab-item value="inspector" label="Derivation inspector" />
<cat-tab-item v-if="showMap" value="map" label="Map" />
apportionmentSummary is a prop, so when it arrives after mount the Apportionment tab appends after "Derivation inspector" instead of sitting where the template puts it. cal/report.vue is exposed the same way — all six of its tabs are conditional on props.
Proposed fix, shared by both components
The parent reads its default slot's VNodes at render time rather than waiting for registration. Works identically on server and client, gives document order for free, and deletes the registration channel, the compareDocumentPosition workaround and the re-registration watcher along with problems 1–3.
function collectItems (nodes: VNode[]): VNode[] {
return nodes.flatMap((n) => {
if (n.type === Fragment) return collectItems(n.children as VNode[]) // v-for, <template>
if (n.type === Comment) return [] // v-if false
return n.type === CatTabItem ? [n] : []
})
}
Two knock-ons:
- Ids move to the parent. Items mint their own with
useId() today, but the parent needs them to label its headers. Derive from a single useId() as ${base}-label-${i} / ${base}-panel-${i} so server and client agree — getting this wrong trades an SSR bug for a hydration mismatch, which is worse.
- Focus by id. The parent holds a
focus() callback from registration today; with the panel id known it becomes document.getElementById(panelId)?.focus().
Constraint, and why it is safe
The scan only sees direct children. v-for, v-if and <template> are fine — the flattener handles Fragment and Comment nodes — but an item wrapped inside another component would become invisible, which registration tolerates today.
Checked every call site across gotransit-editor, www-transit-land-v2, calact-network-analysis-tool and tlv2-ui: 31 usages, all direct children, zero wrapped items. The flattener is required rather than optional — 11 items sit behind v-if (www feed-version.vue, calact report.vue and census-details.vue) and 2 behind v-for (gotransit validation-results.vue, tlv2-ui maps.vue).
Three call sites put a non-item component inside the parent's slot alongside the items — o-loading / t-loading in the two gotransit stepper files, t-field in www map.client.vue. The scan skips them and <slot /> still renders them, so they are unaffected.
Test to flip
src/controls/steps.test.ts has "leaves the progress list empty until hydration", which characterises the current limitation rather than endorsing it. It should flip to asserting markers when this lands.
cat-tabsandcat-stepscollect their children the same way: each item callsregister()inonMounted, and the parent renders its header from the list that accumulates. That one decision causes three separate problems.1. A changed
valuestrands the old entryderegisterruns on unmount with the currentprops.value, and nothing watchesvalueitself, so changing it leaves the old registration in place and adds a second. Fixed incat-step-item(#66, found by Copilot review);cat-tab-itemstill has it.2. Nothing renders on the server
onMountednever runs duringrenderToString, so the tablist / progress list is empty in server HTML and appears only on hydration. This is the SEO-relevant one, and it lands harder on tabs than steps — tabs carry page content on www, a stepper is task UI.#66 shipped a partial mitigation on the steps side:
activeValuefalls back to the model, so the active panel renders visible rather than every panel shipping behinddisplay: none. The same one-line fallback applies tocat-tabs. The header itself stays client-only either way.3. Items mount out of order
An item revealed later by
v-ifregisters last and lands at the end of the header regardless of where it sits in the template.cat-stepsworks around this by comparing panel elements withcompareDocumentPosition;cat-tabsdoes not.This one is live in production, not hypothetical.
calact-network-analysis-tool/app/components/cal/census-details.vuehas a conditional tab in the middle of its list:apportionmentSummaryis a prop, so when it arrives after mount the Apportionment tab appends after "Derivation inspector" instead of sitting where the template puts it.cal/report.vueis exposed the same way — all six of its tabs are conditional on props.Proposed fix, shared by both components
The parent reads its default slot's VNodes at render time rather than waiting for registration. Works identically on server and client, gives document order for free, and deletes the registration channel, the
compareDocumentPositionworkaround and the re-registration watcher along with problems 1–3.Two knock-ons:
useId()today, but the parent needs them to label its headers. Derive from a singleuseId()as${base}-label-${i}/${base}-panel-${i}so server and client agree — getting this wrong trades an SSR bug for a hydration mismatch, which is worse.focus()callback from registration today; with the panel id known it becomesdocument.getElementById(panelId)?.focus().Constraint, and why it is safe
The scan only sees direct children.
v-for,v-ifand<template>are fine — the flattener handles Fragment and Comment nodes — but an item wrapped inside another component would become invisible, which registration tolerates today.Checked every call site across gotransit-editor, www-transit-land-v2, calact-network-analysis-tool and tlv2-ui: 31 usages, all direct children, zero wrapped items. The flattener is required rather than optional — 11 items sit behind
v-if(wwwfeed-version.vue, calactreport.vueandcensus-details.vue) and 2 behindv-for(gotransitvalidation-results.vue, tlv2-uimaps.vue).Three call sites put a non-item component inside the parent's slot alongside the items —
o-loading/t-loadingin the two gotransit stepper files,t-fieldin wwwmap.client.vue. The scan skips them and<slot />still renders them, so they are unaffected.Test to flip
src/controls/steps.test.tshas "leaves the progress list empty until hydration", which characterises the current limitation rather than endorsing it. It should flip to asserting markers when this lands.