fix(frontend): each shared <style src> sheet sits at one block index everywhere, so vite build cannot depend on transform order (#4988)
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m40s
CI & Build / Python tests (push) Successful in 2m37s
CI & Build / Build & push image (push) Successful in 56s

Run 8271 failed in the image build: "[vite:vue] Cannot read properties of undefined (reading 'scoped')" on moments-shared.css. plugin-vue caches one descriptor per src file and answers ?index=N from whichever component registered it last. RuleEditorSlideOver had moments-shared at block 2, while its other two importers have it at block 1. Step 2's new imports changed the transform order and exposed the bug.

- RuleEditorSlideOver: moments-shared is now loaded with an @import instead of a third <style src>.
- rules-shared.css sat at block 0 in the five panes and at block 1 in LessonDetailView, LessonEditorView and RuleEditorSlideOver, which is the same latent hazard. The three move to block 0.
- tests/test_frontend_shared_styles.py: a guard that every <style src> sheet sits at one index, plus a test that the guard can fail. vue-tsc cannot see this hazard, and vite build only runs after every test has passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 09:58:41 -04:00
co-authored by Claude Opus 5.5
parent 07d2542479
commit 1774ee3696
4 changed files with 77 additions and 13 deletions
@@ -414,6 +414,12 @@ watch(() => props.ruleId, load);
</div>
</template>
<!-- `.rule-chip` for the "suggested" marker on a lesson — the chip the rule
panes already use, loaded here because the slide-over can open with no
pane that loads it (a link straight to ?rule=N).
Block 0, as in every pane that loads it: a shared src must sit at
one index everywhere (tests/test_frontend_shared_styles.py). -->
<style src="@/assets/rules-shared.css" />
<style scoped>
.kind { border: 1px solid var(--fs-border-color); border-radius: var(--fs-radius-md); padding: var(--fs-space-3); margin: var(--fs-space-3) 0; }
.kind legend { font-size: var(--fs-size-tiny); text-transform: uppercase; letter-spacing: var(--fs-tracking-tiny); color: var(--fs-text-tertiary); padding: 0 var(--fs-space-2); }
@@ -518,8 +524,11 @@ legend { padding: 0 0.35rem; font-size: 0.8rem; color: var(--fs-text-tertiary);
.trash:hover, .close:hover { opacity: 1; }
</style>
<!-- `.rule-chip` for the "suggested" marker on a lesson — the chip the rule
panes already use, loaded here because the slide-over can open with no
pane that loads it (a link straight to ?rule=N). -->
<style src="@/assets/rules-shared.css" />
<style src="@/assets/moments-shared.css" />
<!-- An @import, not a second <style src>: plugin-vue caches ONE descriptor per
src file, and every other importer of moments-shared.css has it as style
block 1. Here it would be block 2, so whichever SFC registered last decided
the lookup and the production build failed with "reading 'scoped'" when
the transform order changed (tests/test_frontend_shared_styles.py). -->
<style>
@import "@/assets/moments-shared.css";
</style>
+5 -4
View File
@@ -313,6 +313,11 @@ onMounted(load);
</div>
</template>
<!-- `.rule-chip` and its preference variant: the marker a rule's kind wears
everywhere else, reused rather than re-spelled.
Block 0, as in every pane that loads it: a shared src must sit at
one index everywhere (tests/test_frontend_shared_styles.py). -->
<style src="@/assets/rules-shared.css" />
<style scoped>
.lesson-detail {
max-width: 780px;
@@ -459,7 +464,3 @@ onMounted(load);
font-size: 0.88rem;
}
</style>
<!-- `.rule-chip` and its preference variant: the marker a rule's kind wears
everywhere else, reused rather than re-spelled. -->
<style src="@/assets/rules-shared.css" />
+5 -4
View File
@@ -459,6 +459,11 @@ onMounted(() => {
</div>
</template>
<!-- `.rule-chip-preference` marks a preference among the rules offered, as it
does everywhere else a rule's kind is shown.
Block 0, as in every pane that loads it: a shared src must sit at
one index everywhere (tests/test_frontend_shared_styles.py). -->
<style src="@/assets/rules-shared.css" />
<style scoped>
.lesson-editor {
max-width: 820px;
@@ -606,7 +611,3 @@ onMounted(() => {
.le-small { padding: 0.2rem 0.7rem; font-size: var(--fs-size-label); }
.le-ghost:disabled { opacity: var(--fs-disabled-opacity); cursor: default; }
</style>
<!-- `.rule-chip-preference` marks a preference among the rules offered, as it
does everywhere else a rule's kind is shown. -->
<style src="@/assets/rules-shared.css" />
+53
View File
@@ -0,0 +1,53 @@
"""A stylesheet shared by `<style src>` must sit at the same block index in
every component that loads it that way.
plugin-vue caches one SFC descriptor per `src` file and resolves a request for
`moments-shared.css?vue&type=style&index=N` against whichever component last
registered it. When two components carry the same src at different indexes,
the lookup can land on a component with fewer style blocks, and the production
build dies with "Cannot read properties of undefined (reading 'scoped')". It
depends on transform order, so an unrelated change, such as a new import in a
view, is enough to flip it. vue-tsc cannot see it; only `vite build` can, and
that runs at image-build time, after every test has passed.
"""
from __future__ import annotations
import pathlib
import re
from collections import defaultdict
ROOT = pathlib.Path(__file__).resolve().parents[1] / "frontend" / "src"
STYLE_TAG = re.compile(r"<style\b([^>]*)>")
SRC_ATTR = re.compile(r'\bsrc="([^"]+)"')
def src_indexes(sources: dict[str, str]) -> dict[str, dict[int, list[str]]]:
"""{src: {block index: [component, ...]}} over a set of SFC sources."""
seen: dict[str, dict[int, list[str]]] = defaultdict(lambda: defaultdict(list))
for name, text in sources.items():
for index, attrs in enumerate(STYLE_TAG.findall(text)):
match = SRC_ATTR.search(attrs)
if match:
seen[match.group(1)][index].append(name)
return seen
def disagreements(sources: dict[str, str]) -> dict[str, dict[int, list[str]]]:
return {src: dict(at) for src, at in src_indexes(sources).items() if len(at) > 1}
def test_every_shared_style_src_sits_at_one_block_index():
sources = {str(p.relative_to(ROOT)): p.read_text() for p in ROOT.rglob("*.vue")}
assert sources, f"no .vue files under {ROOT}"
assert disagreements(sources) == {}, (
"a <style src> sheet sits at different block indexes across components — "
"load it with @import in the odd one out"
)
def test_the_guard_can_fail():
sources = {
"A.vue": '<style scoped></style>\n<style src="@/x.css" />',
"B.vue": '<style scoped></style>\n<style src="@/y.css" />\n<style src="@/x.css" />',
}
assert disagreements(sources) == {"@/x.css": {1: ["A.vue"], 2: ["B.vue"]}}