Skip to content

docs: add how-to for adding a new version of a slot - #344

Open
brian-smith-tcril wants to merge 2 commits into
openedx:mainfrom
brian-smith-tcril:slot-versioning-how-to
Open

brian-smith-tcril wants to merge 2 commits into
openedx:mainfrom
brian-smith-tcril:slot-versioning-how-to

Conversation

@brian-smith-tcril

@brian-smith-tcril brian-smith-tcril commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Summary

A how-to for adding a new version of a slot, docs/how_tos/add-a-slot-version.md, with tests that run its examples and a guard that fails when the markdown and the tested code drift apart. Docs and tests only; the runtime is unchanged.

ADR 0009 sets the rules for a slot's contract and lifecycle but not the steps. The how-to walks the new-version path on four invented examples, from a prop rename in full to a slot whose default widget takes widget options, each ending with the old version's removal, and then two renames of a slot: one with its contract unchanged, where the old id is kept as an alias, and one where its props change, where ADR 0009 calls for a new identifier and a new slot wraps the old one. Every version of every example is a fixture the tests render, so the code in the how-to is code that runs. The examples follow the answer on #339 that an app exports the types its slot props are built from (comment).

Resolves #340.

What changed

  • docs/how_tos/add-a-slot-version.md: an overview and six examples. Renaming a prop, in full: v2 wrapping v1, @deprecated notes, a README per version, the feat: commit, the later feat!: removal, and a closing section on moving an operator's widget to v2. Changing a prop's type, under the same step headings, showing only what differs. A prop that becomes a list, where the old prop has no direct source in the new one and the list is added to v1 as well. A slot whose default widget takes options, where the item type gains a required field and the widget serves v1 and v2 operators' options at once until v1 is removed, with the whole slot file shown after the deprecation step and after the removal. Renaming a slot, where nothing in the contract changes: the old id is kept in idAliases until a later feat!: removes it, and the slot keeps its one README. Renaming a slot when its props change: the new slot wraps the old one under the new-version steps, and the old README is renamed README.songList.v1.md, named for the old slot minus what its id shares with the new one, the rule that also names README.v1.md. The how-to shows that rule by example only; where it gets written down is Decide where a "documenting a slot" doc lives #346.
  • runtime/slots/testMocks/: twenty-six fixtures, each example slot in the three states the how-to shows (v1 only, v2 wrapping v1, v2 after removal), plus the two states the rename says not to produce, the operator's widget before and after its move, the list example's state before the list is added to v1, and the options example's operator operation and the page that renders the slot before and after the change. The two rename examples hold the slot before, with the alias or the wrapping, and after the removal, plus the id renamed with no alias.
  • runtime/slots/howToExamples.test.tsx: renders each state the ways an operator can use it (component widgets against either version, replacing the default content, replacing the layout and reading the props through useSlotContext(), widgets on both versions at once, a v1 operation on the v2-only state) and checks the reasons the how-to gives: a widget reading a renamed-in-place prop gets undefined and the typecheck rejects it, a widget on a bumped-in-place id stops rendering, a v1 widget gets the first tag or an empty string, a v1 option reaches the default widget only while v1 exists, a v2 option's function never sees a tree a v1 option added, and useWidgetOptions() inside the wrapped v1 reads v1's options while useWidgetOptionsForId on the v2 id reads v2's.
  • runtime/slots/howToExamples.drift.test.ts: reads the how-to and asserts that every ts or tsx block under an example heading, and both sides of every diff block, appears in one of that example's fixtures, with every comment line checked verbatim and every code fence tagged with one the guard reads.

Open question

How do people find out a slot version is deprecated? A feat!: subject or a BREAKING CHANGE: footer is easy to search for, but the feat: commit that adds v2 and deprecates v1 is not, and the deprecation sits there for at least a named release before the breaking removal. Should deprecations be tracked somewhere findable: a commit convention, a list in the repo, the DEPR ticket alone, or something else? The how-to's step 5 says only what the commit body should say.

Testing

npm run typecheck and npm run lint clean. npm run test -- runtime/slots: 9 suites, 287 tests pass, 228 of them in the two new files. No manual testing; docs and tests only.

Decisions

The decision log kept while writing, from the first draft through two review rounds and the two rename examples added after openedx/frontend-app-catalog#174 asked for them.

Full decision log
  1. Nothing from the FPF era is cited. The three v1/v2 pairs in the org (recorded in the plan, Prior art) shaped the examples only. Brian, 2026-11-02.

  2. Examples are invented, ordered least to most complex: a prop rename, a prop type change, several changes at once. The rename is written; the other two are placeholders in the first draft. Brian, 2026-11-02.

  3. One arrangement, v2 wraps v1. The how-to prescribes it and does not describe siblings or v1-wraps-v2. Every existing pair in the org took it, and it is the one where every old operation keeps working and a new-version replace takes the old version with it.

  4. @deprecated notes are split by where an editor shows them: the interface note names the new interface and the new slot id; each renamed member's note names its new name. Brian's reaction to the first wording, 2026-11-02.

  5. Props interfaces are versioned from the first bump (GreetingSlotV1Props, GreetingSlotV2Props), with the unversioned name kept as a deprecated alias of v1 until v1 is removed, so a site config typed against it compiles through the coexistence window. Open question 2 of the plan, taken provisionally in the draft; not yet confirmed by Brian.

  6. The DEPR ticket is filed when the new version first ships from stable, not when it lands on main. Brian, 2026-11-03: main publishes alpha prereleases (.releaserc: main → channel alpha, stable → latest), so there is no ticket to link at commit time. The README.v1.md warning therefore says only "deprecated and will be removed in a future release" and gets the ticket link added later by step 6. Supersedes the earlier reading of ADR 0009 §3 as "at landing" (plan open question 1); the draft still cites ADR 0009 §3 for the process and does not cite ADR 0018 (docs: ADR 0018, what counts as a breaking change for an app #343). Step dropped from the draft on 2026-11-03: Brian wants the DEPR-ticket step written after the PR-body question about tracking deprecations (plan, "Open questions for the PR body") has been discussed. The removal step still says to wait for the deprecation process.

  7. Runtime warnings, pre-release apps, and a link from ADR 0009 are not mentioned (plan open questions 3, 4, 5), pending Brian.

  8. File is docs/how_tos/add-a-slot-version.md, Markdown, title "How to: Add a New Version of a Slot". The first draft was slot-versioning.md, "How to: Change a Slot's Props"; Brian found both titles confusing (2026-11-02), and the readability pass renamed the file after the procedure the reader follows. The link from the migration how-to's "Refactor slots" section is not yet added.

  9. Each slot version gets its own README: README.md is the current version and links to the previous one, README.v1.md is the old version with the deprecation note at its top. Brian recalled FPF slots not sharing one README across versions (2026-11-03); checked this session: all three surveyed v1/v2 pairs keep separate files (authoring CourseAuthoringUnitSidebarSlot/README.md + README.v1.md in refactor: CourseAuthoringUnitSidebarSlot frontend-app-authoring#2000; header v1/README.md + v2/README.md subfolders in feat: Add HeaderNotificationsSlot to enable notifications tray default ON frontend-component-header#663 and #682). The how-to uses the sibling-file layout and, per decision 1, does not cite any of them.

  10. The person inserting widgets is an "operator", ADR 0009's word, not "plugin author" (ADR 0011's). "Site config" is named only where the operator's diff is shown. Brian's objection to site configs framing the overview, 2026-11-03.

  11. The overview and first example were rewritten with Brian sentence by sentence (2026-11-03). Settled framing: a change that would break the contract cannot land without bumping the version; a bare bump is a breaking change to the app; ideally both versions coexist with the old one deprecated and removed later; the guide provides examples of some ways to do that. "Breaking change" is said only of the app, never as a label for the category of slot change. The classification table is gone: which changes need a new version is ADR 0009's and docs: ADR 0018, what counts as a breaking change for an app #343's job. The rename example opens with two what-not-to-do diffs (rename in place; bump without keeping v1) before the steps.

  12. The examples are tested from runtime/slots, not docs/ (Brian, 2026-11-03). Jest's projects are tools, runtime, shell, and tsconfig.json includes only runtime/** and shell/**, so nothing under docs/ runs or type-checks. Fixtures live in runtime/slots/testMocks/ because tsconfig.build.json excludes **/testMocks/** from the published build (precedent: runtime/testMocks/, shell/testMocks/). One fixture per state the markdown shows (three per example). howToExamples.test.tsx runs the operator-usage matrix from the plan; howToExamples.drift.test.ts asserts every tsx block under an ## Example: heading is a verbatim substring of one of that example's fixtures, ignoring import lines (fixtures import Slot by relative path; no @openedx/frontend-base self-mapping exists) and doc-comment lines (the how-to shows the interfaces with and without @deprecated notes), with each @deprecated line checked separately. Results at commit time: npm run test -- runtime/slots 111 passed; npm run typecheck clean, including widgets typed ({ userName }: GreetingSlotProps) with no cast, so the how-to's operator diff compiles under the repo's config; eslint . and lint:tools clean; the test-site lint step needs a built dist/ (openedx CLI) and was not run.

  13. Review round, 2026-11-03. A fresh review of the branch, with no context from the writing. Taken: the step 2 bullet claiming a v2 layout "replaces v1 and everything in it" contradicted useWidgets() (a replacement layout still receives defaultContent, the inner v1 slot) and the test "keeps v1 and its widgets inside a replaced v2 layout"; it is now its own bullet saying the layout receives v1 as its defaultContent widget. "Computed once" dropped from the rename's step 2 (nothing is computed there). Step 3 heading is "Mark the old version as deprecated" in both examples (the "where an operator will see it" clause promised a place the body no longer named once the editor sentence was cut). The drift test now checks both sides of every diff block, not only tsx fences: Brian's rule, "either we check the diffs or not", since even the what-not-to-do diffs produce valid slots. That needed fixtures for the two what-not-to-do states (GreetingSlotRenamedInPlace.tsx, GreetingSlotBumpedInPlace.tsx) and for the operator's widget before and after its move (greetingWidgets.tsx), each with a behaviour test that verifies the how-to's stated reason: the renamed-in-place slot gives a widget reading userName undefined and the typecheck rejects that widget (one test, @ts-expect-error on the widget; Brian merged the earlier two tests because a jest test cannot assert a compile failure); the bumped-in-place slot no longer renders a v1 widget; the operator's widget replaces the default content on the states each side of the diff applies to. The four useSlotContext() as unknown as X casts became one useSlotProps<Props>() helper; Brian accepts it in a test only. Nothing in the runtime gives a layout a typed read of the slot's props (SlotContext is its fixed members plus an index signature; the dev showcase narrows with typeof); a possible runtime issue, outside this how-to. Kept as written: "ideally" (Brian: coexistence may not always be possible, deprecating without a replacement would be the rare exception); the alias note "Renamed to GreetingSlotV1Props" (true, one hop more); no sentence on the Banner stand-in's own props changing (it exists only for the tests). Deferred: an OPTIONS test to the widget-options example, since neither example takes options. Not decided: "Moving a widget to v2" sitting after step 6, an idAliases steer, the README and index.ts conventions being stated without repo precedent.

  14. The third example is "a prop that becomes a list", not "several changes at once" (Brian, 2026-11-03). A taggable item slot's (TaggableItemSlot, Brian's name over "filter") selectedTag: string becomes selectedTags: string[]. The nearest v1 value, selectedTags[0], loses the rest of the list, so v1 gains selectedTags as well (adding a member does not break the contract) and selectedTag is deprecated within v1 pointing at selectedTags; v2 is v1 without the old member. Brian's reasoning: a v1 widget only sees the inner slot's props, so adding the list to v1 is the only way it can get the rest without moving slot id; the removal of the deprecated member is the break, so the removal is what gets the new version. Step 4 differs from the rename for the first time (README.v1.md documents the new prop). The banner could have done the same (v1 gaining notice) but its message is exact, not degraded, so it is not retrofitted. A widened union of allowed values was considered for this example and set aside with the widget-options example. Fixtures TaggableItemSlotV1.tsx, TaggableItemSlotV1V2.tsx, TaggableItemSlotV2.tsx; the usage matrix plus one test that a v1 widget gets the first tag under the old name and the whole list under the new one when two are selected. Results: npm run test -- runtime/slots 158 passed, typecheck clean, eslint . and lint:tools clean.

  15. Every example opens the same way: the slot, its code, the change in one sentence (Brian, 2026-11-03). The banner's and list example's prefaces ("This example follows the same steps as the rename. ...") jumped between ideas before the reader had seen the slot; "follows the same steps" is redundant with the repeated headings and the "Same as for the rename" steps. The idea each preface carried moved to the step that acts on it: the banner's is already step 2's sentence; the list's ("the old prop can hold only one of the tags") now opens step 1.

  16. The list example's step 1 is split: the versioned interfaces first, then "selectedTags can also be added to v1 as a non-breaking change" with a diff adding it (Brian, 2026-11-03). Three earlier wordings of a single step 1 paragraph failed because they asserted the benefit of adding the list ("instead of only the first tag") before step 2 establishes that v1 receives selectedTags[0]; Brian spotted that nothing in step 1 says the first entry of the list is selectedTag. Each fact now sits at the step that shows it: the mapping and the benefit in step 2, the deprecation in step 3. Settled after three corrections from Brian the same hour as three blocks in order: the old interface under its versioned name with the alias; the new interface; then "selectedTags can also be added to v1 as a non-breaking change" with a diff adding it to TaggableItemSlotV1Props. The two rejected shapes were one block holding both interfaces (the v1 part did not belong in a block meant to show v2) and a diff against TaggableItemSlotV1Props with no block declaring it (it assumed v1 existed when it did not yet). The state after the first two blocks, v2 wrapping v1 with only the first tag passed down, is the fixture TaggableItemSlotV1V2FirstTagOnly.tsx, with one behaviour test: a v1 widget sees only the first tag when two are selected. The claim test (a v1 widget gets the first tag under the old name and the whole list under the new one) runs on v2 wrapping v1. Results: npm run test -- runtime/slots 162 passed, typecheck clean, eslint . and lint:tools clean.

  17. The list example's steps 1 and 2, settled line by line with Brian (2026-11-03). Register is imperative, as in the rename's "Give the two interfaces versioned names": prose that narrates what the block shows ("the old interface is renamed", "v1 gets the first item") was rejected each time as "saying x is done". Step 1's three lead-ins: "Version and alias the old interface." / "Declare the new interface with only the list." / "selectedTags can also be added to v1 so widgets in the v1 slot can read the list. This is purely additive and non-breaking." (Brian's sentence, minus the "You can" he first wrote; the how-to has no "you"). Step 2 opens with the general point, since this is the first example where the old prop has no direct source in the new ones: "Without a 1:1 mapping from the new props to the old ones, there is a choice to make about what the old slot gets. Here v1 expects a single tag and v2 has a list." Rejected on the way: "the maintainer's call" and "judgement call" (a reader following the how-to is the maintainer and should not feel the choice is someone else's), "decided case by case", "whatever makes sense on a case-by-case basis", "is a decision" standing alone; "there is a choice to make" carries Brian's "a choice you'll need to make" without the pronoun. The wrapper is a function so the choice is a named line with a comment: const selectedTag = selectedTags[0] ?? ''; under "// v1 takes a single tag. This example gives it the first of the list, or an empty string when the list is empty." Brian caught the empty-list hole (selectedTags[0] is undefined against a string contract; the repo does not enable noUncheckedIndexedAccess, so the typecheck is silent); of falling back to '', not rendering v1 when empty, or typing the list non-empty, he chose the fallback. The drift test now also requires every // line in a how-to block to appear verbatim in a fixture, as it did for @deprecated lines, so the comment is guarded. One test added: with an empty list on v2 wrapping v1, a v1 widget reading selectedTag renders "" (asserted through JSON.stringify, which tells '' from undefined). Results: npm run test -- runtime/slots 164 passed, typecheck clean, eslint . and lint:tools clean.

  18. The list example's steps 3 and 4 (Brian, 2026-11-03). Step 3 prose is "Add the @deprecated notes as in the rename."; wordings that spelled out where the member's note points ("selectedTags, which v1 already has") were rejected as awkward and obvious, and "The interface's note points at v2" as not a how-to sentence. The member's note reads "This is the first of selectedTags, or an empty string when there are none. Read selectedTags instead.", matching the fallback. Step 4 is "Same as for the rename."; the README remark about the new prop was not about giving versions READMEs, so it moved to step 1 as "Also update the slot's README." after the diff that adds the list to v1. The redundant paragraph after step 2's code (what a v1 widget sees; that it can read the list) was cut: the comment and step 1's third line already say both. Both commits amended (hashes change with every amend, so none are recorded here): the docs body now names the three examples, the tests body names the list fixtures and cases and the comment check. The overview lost its "instead" the same day, amended into the docs commit. Rewording a non-tip commit here needs GIT_EDITOR set on the command, because the session environment sets GIT_EDITOR=true, which outranks -c core.editor.

  19. idAliases is not used for expand/contract, and the how-to does not offer it (Brian, from a discussion with Adolfo, relayed 2026-11-04). Example: a slot with optional propA is v1; add optional propB, bump the id to v2, keep the v1 id as an alias. Both ids are now one slot with one props interface holding propA and propB, so removing propA breaks v2 as well as v1 and needs a v3. An alias never lets the contract contract. Wrapping gives each version its own interface, so removing the inner v1 is the contraction and v2's contract never grows. idAliases stays what Slot's own comment says it is: renaming an id with the contract unchanged. The reviewer's "idAliases is never mentioned" (decision 13) is answered by this; whether a sentence saying so goes in the how-to or only in the PR body is Brian's call.

  20. Fourth example, "a slot whose default widget takes options", written 2026-11-04 (Brian: "go ahead and write it up, it might be helpful to see the example instead of talking about it as a hypothetical"). Mechanism, read in runtime/slots/widget/hooks.ts, WidgetProvider.tsx and utils.tsx: every widget renders inside a WidgetProvider carrying its slot's id and its own id; useWidgetOptions() merges the OPTIONS operations on that slot naming that widget; with v2 wrapping v1 the default widget is v1's defaultContent, and v2's defaultContent is the v1 <Slot> element, so a v2 operator's OPTIONS on defaultContent matches an element that reads nothing. useWidgetOptionsForId(slotId, widgetId) is exported, so the default widget reads v2's options by id as well, merged with v2 winning. Choices taken without Brian (offered, not yet confirmed): the trigger is a plain rename (links to relatedLinks) so steps 1, 3, 4 and 5 are "Same as for the rename" and only the options differ; the default widget takes the slot's value as a JSX prop from the wrapper rather than through useSlotContext() (the catalog's DefaultSocialLinksWidget reads context with the cast Brian dislikes); one scalar option, max, not the catalog's function-valued list. Brian's invariant from the alias discussion (decision 19) holds and is stated at step 6: a v2 operator's OPTIONS on defaultContent reaches the widget by id before the removal and through useWidgetOptions() after, so a v2 site config does not notice the removal; the usage matrix's v2 wrapping v1 and v2 only columns are the check. The how-to shows the operator's OPTIONS operation as a ts block, so the drift test now reads ts fences as well as tsx and that block has a fixture, relatedLinksOptions.ts. Fixtures RelatedLinksSlotV1.tsx, RelatedLinksSlotV1V2.tsx, RelatedLinksSlotV2.tsx. Tests beyond the matrix: the operator's v1 max caps the list on v1 only and wrapped (the OPTIONS test deferred in decision 13); a v2 max caps it wrapped and on v2 only; a v2 max wins over a v1 max wrapped; the v1 max does nothing on v2 only. Results: npm run test -- runtime/slots 197 passed, typecheck clean, eslint . and lint:tools clean. ADR 0009 does not mention widget options (grepped), so the example does not make the option the reason for the bump.

  21. The widget-options example is a tree list gaining a required foliage, not related links with a max (Brian, 2026-11-04, after a long search). Brian rejected the related links version because the spread merge of two option objects felt specific; a survey of every useWidgetOptions/useLayoutOptions call in the org (catalog CourseAboutSidebarSocialSlot; frontend-base shell CoursesLink, WidgetWithOptions, RevealLinks, LabeledLinkColumn, NavDropdownMenuSlot; docs.openedx.org porting how-to) showed one source per consumer and a ?? fallback, nobody merging two, because no slot with options has been versioned yet. The social links slot was the model: its option is a transformation of the prop (an array or a function of the current list), so what breaks a v1 operator's function is the item shape changing. Brian's technical shape: "we had this info in a free-text description field, now we're moving a piece of it to a structured field and have decided it is required going forward". Constraints that emerged from the rejections, in order: not learning-focused (uncanny valley), not subjective (roast, heat, size), not a messy enum (USB versions, wattage, PD's 100 W), not a field that would obviously have been structured from day one (book format, vehicle fuel), not a fact the app could default to (tabs' /${id}, bulbs' "not dimmable", "the provider v1 had"), not per-part of the item (venue access vs a balcony seat), not open-ended (credit roles), not theming (Paragon's variant work), not the real social slot with visible labels (too close, a design change Brian would argue against, and a derived string instead of an enum). The conflict named along the way: a UI-level list the app seeds is homogeneous on any field it did not need yet, so a new field always has a sensible default there; a required field needs items that genuinely differ on a fact the app cannot know, which is domain data. Brian accepted domain data; the "fake real thing" had to feel right. Trees won because Open edX releases are named after them. Field foliage: 'evergreen' | 'deciduous' (Brian: "foliage", from the botanical definition of evergreen, not "leaves"); seeded Redwood, Sumac, Willow at first, then (Brian, same day: the names should follow the release order rather than jump) Redwood, Sumac, Teak, then reversed to newest first, Teak, Sumac, Redwood, with the operator's v1 option adding Quince, the release before Redwood (Brian, same day); Teak and Quince are deciduous, Redwood evergreen. Brian's rendering: v1 lists names only; v2 prefixes 🌳 or 🌲; 🪾 for a v1-added tree, which he notes nobody would really want for a slot but reads well in an example. Shape of step 2, carrying the invariant from decision 19: v2's option is applied to the plain v2 list first, exactly as it will be after removal, then v1's option on top to v1-shaped trees (foliage stripped going in, restored by id coming out, 'unknown' for a tree it added), so a v2 operator's function never receives 'unknown' and the v2 contract is the same before and after removal; ShownTree with 'unknown' is the widget's own type and the removal diff deletes it. Tree is versioned (TreeV1, TreeV2, deprecated alias) under the exported-types rule, with the options interfaces local and versioned alongside. Fixtures TreeListSlotV1.tsx, TreeListSlotV1V2.tsx, TreeListSlotV2.tsx, treeListOptions.ts (addOliveV1). Tests beyond the matrix: the v1 option lists Olive on v1 only, marks it 🪾 wrapped, is ignored on v2 only; a v2 option's Olive shows 🌲 wrapped and on v2 only; a v1 rename keeps the tree's foliage; a v2 function sees exactly ['evergreen', 'deciduous', 'deciduous'] with a v1 option adding Olive (the invariant); a v2 filter and a v1 add both apply. defaultContentText became string | RegExp because v1 prints "Redwood" and v2 "🌲 Redwood". Results: npm run test -- runtime/slots 210 passed, typecheck clean, eslint . and lint:tools clean. Supersedes the related links parts of decision 20. Same day, description dropped from Tree (Brian): the free-text-to-structured story was the design conversation's, not the how-to's; the example's point is that the list now expects something new (🌲 vs 🌳), so a new required field is clear on its own. The change sentence is now "Each tree is to show its foliage, evergreen or deciduous, which is a new required field."

  22. No generic helpers in the how-to's code (Brian, 2026-11-04). The tree example had a function resolve<T>(option, current) for the array-or-function option; the catalog's DefaultSocialLinksWidget resolves it inline with a typeof ... === 'function' ternary. Brian: generics make an example harder to scan, "template stuff can confuse people". The how-to and fixtures now inline the ternary at each of the three call sites, matching the catalog line for line. General rule for the examples: match the shape of real code in the org over a tidier abstraction.

  23. The tree example shows the app rendering the slot (Brian, 2026-11-04: "the slot is rendered in the application with a default list of trees"). The intro gains "The app renders the slot with its default list of trees:" and a <TreeListSlot trees={[...]} /> block with Teak, Sumac, Redwood; step 2 gains "The app's own list gains the field:" and a diff adding foliage to each, which is where the required field reaches the app's own code. The default widget is DefaultTreeListWidget, the catalog's DefaultSocialLinksWidget pattern in full, because DefaultTrees read as "the default trees" when the widget renders the list after options (Brian). Fixtures TreeListPageV1.tsx and TreeListPageV2.tsx hold the two renders; two tests render each page and assert the names, plain and with icons. Results: npm run test -- runtime/slots 215 passed, typecheck clean, eslint . and lint:tools clean.

  24. TreeV2 does not extend TreeV1 (Brian, 2026-11-04). The new version's types stand alone so that removing the old version's type at the end of the deprecation is a deletion, not a refactor of v2. TreeV2 declares id, name and foliage itself; only the widget's own ShownTree, which the removal deletes anyway, is built from TreeV1. General rule for the examples: nothing in a v2 declaration references a v1 one. The change sentence is now Brian's: "A new required foliage (evergreen or deciduous) field is being added."

  25. The tree example's steps 1 and 2 are shown in pieces (Brian, 2026-11-04: step 1 "a lot going on in that one block", step 2 "dense and hard to parse"). Step 1: old interfaces versioned and aliased; new ones with the field; the option types (local, unexported, so no alias; Brian had missed the lack of export). Step 2, in order: the wrapper; the app's list gaining the field (diff); the widget's own ShownTree and icon map; the two option reads with the one comment that explains them; v2's option applied first; v1's applied to v1-shaped trees and the render. The long ternaries are now three lines each, as the catalog writes them, in the how-to and all three fixtures. The two code comments that restated the lead-ins were removed from the fixture too, so the how-to and the fixture carry the same comments. Each piece is a contiguous substring of TreeListSlotV1V2.tsx, which was reordered (old declarations first) to keep that true. Results: npm run test -- runtime/slots 217 passed, typecheck clean, eslint . and lint:tools clean.

  26. Step 2 of the tree example starts from the v2 widget and adds the v1 parts as diffs (Brian, 2026-11-04: "the first thing we should show is the widget switching to do the foliage stuff, then all the parts around it to make everything continue to work"). Order: the wrapper; the app's list diff; "Rebuild the default widget for v2, as it will be once v1 is gone" with the v2-only widget as a tsx block (a substring of TreeListSlotV2.tsx); then three diffs whose before sides are that widget and after sides the wrapped one: the two option reads, ShownTree with the 🪾 icon, and the two applications. The step 6 diff is the exact reverse. No numbering inside the step; lead-ins carry the order. Fixtures unchanged; the drift guard checks each diff side against the v2-only and wrapped fixtures.

  27. Two adaptations, two places, and no sentinel (Brian, 2026-11-04). Brian found undoing the 'unknown' entry in FOLIAGE_ICON at removal awkward, and named the two moving pieces: v1 options have to be adapted for the default widget, which wants v2 trees, and v2's list has to be adapted for v1 replacement widgets and layouts, which do not expect foliage. The wrapper now strips foliage before the v1 <Slot> (trees={trees.map(toTreeV1)}), the way the list example's wrapper computes selectedTag; the earlier version passed v2 trees to v1 consumers, a field they were never promised. In the widget, a v1 option is given the stripped list and each tree it returns gets its foliage looked up by id in the v2 list; a tree it added has none. ShownTree is TreeV1 & { foliage?: Foliage }, the icon map is the v2 one untouched, and the render shows 🪾 when foliage is missing. Names say what each list is: v2Trees, withoutFoliage, v1Trees, shown (the earlier afterV2/afterV1 said when a value existed). toTreeV1 is a one-line non-generic helper used by both the wrapper and the widget. Step 2 now ends with the "allow a tree without foliage" diffs after the application, and step 6's diff covers toTreeV1, ShownTree, the widget and the wrapper. Lead-ins in Brian's register: "This can be worked around by reading v2's options by id" ("Read ... as well" read as a demand; "x does y" rejected). New test: a v1 widget on v2 wrapping v1 receives {"id":"teak","name":"Teak"}, no foliage. Results: npm run test -- runtime/slots 224 passed, typecheck clean, eslint . and lint:tools clean.

  28. All v1 handling in the tree widget lives in one function, applyV1Option; v2's resolution stays inline (Brian, 2026-11-04). Brian found the two-list-and-lookup body "a nightmare to read" and, more to the point, something he could not feel confident removing. He confirmed that a v1 option can legitimately return stripped objects, so the by-id restore cannot be dropped (the alternative of not stripping and trusting spreads was rejected on that ground). The shape: v2's option is resolved inline as the catalog does; applyV1Option(option, trees) strips to v1 trees, applies the option, and restores foliage through a small named lookup, const foliageOf = (id: string) => trees.find(tree => tree.id === id)?.foliage; (a Map built from entries was tried first and read oddly to Brian: two maps and an invisible tuple in one line); the widget body is const shown = applyV1Option(v1Options.trees, v2Trees);. A named applyOption for v2 was offered and rejected: inline makes the removal clearer. TreeListV1Option is a named type so the function's signature needs no indexed type. The removal diff in step 6 deletes toTreeV1, ShownTree, applyV1Option, the v1 read, the shown line, the 🪾 render and the inner <Slot>, and nothing else. The drift guard caught a +-only diff whose before state existed in no fixture (context on both sides of the added line); its trailing context line was dropped. Results: npm run test -- runtime/slots 227 passed, typecheck clean, eslint . and lint:tools clean.

  29. The tree example's step 3 ends with the whole slot file in a <details> block, and @deprecated stays on exported types and props only (Brian, 2026-11-04: "keep deprecated meaning deprecated"). The tree example is the only one shown as diffs, so a reader needs one place to see the file whole; step 3 is the last step that touches it, and the file after step 3 is exactly TreeListSlotV1V2.tsx, so the block is generated from the fixture (header comment dropped, the two relative imports replaced by one package import) and the drift guard checks it as an ordinary tsx block. Internals (applyV1Option, toTreeV1, ShownTree) carry no @deprecated: the tag means callers should migrate, they have no callers, and the step 6 diff is already the inventory of what the removal deletes. Step 6 ends the same way with "The slot after step 6", generated from TreeListSlotV2.tsx, placed before the invariant sentence (Brian, same day). The trees member note now names the member: "trees in TreeListSlotV2Props is TreeV2[]. A v1 option cannot set a tree's foliage." Results: drift test 84 passed.

  30. The tree example's step 6 diff is the whole file, generated from the two fixtures (Brian, 2026-11-04, noticing the earlier diff left out the deprecated v1 types, the option types and the import). It is produced with difflib.unified_diff over TreeListSlotV1V2.tsx and TreeListSlotV2.tsx with the relative imports replaced by package imports, hunk header dropped, the import's -/+ pair kept adjacent, so both sides are exact substrings of the fixtures and the drift guard checks them. Same shape as the rename's step 6. Results: drift test 89 passed.

  31. The widget-options example is amended into the two commits (Brian, 2026-11-04). The how-to section into the docs commit, whose body now names four examples; the six tree fixtures, the test additions, the drift guard's ts fence support and the removal of the related links files into the tests commit, whose body now describes the options cases. Branch is two commits on 72d7053, tree clean, nothing pushed. Still open: the three review items left to Brian (placement of "Moving a widget to v2", an idAliases sentence, the README and index.ts conventions without repo precedent), the DEPR-ticket step pending the PR-body question, the migration how-to link, and the widened-union example.

  32. The "Exported types" section is gone; what the examples needed is in them (Brian, 2026-11-04: it felt out of place). It was the one rule-stating section among examples, sat after the examples that linked to it, and half of it, renaming an exported type with its shape unchanged and keeping a deprecated alias "until the next breaking release", was the only case in the document with no slot version involved and on a different clock (the reviewer's point in decision 13). The export convention stays where it is first needed, the banner's step 1 ("exported from the app's index.ts beside the slot props, so that a widget can name it"); the shape-change rule is stated in the tree's step 1 ("since a change to the tree's shape is a change to every slot that passes it") instead of linked. The rename-with-alias rule went to the PR body as open question 2 (plan); dropped again, decision 36. Drift test unaffected: the section's ts block was not under an example heading.

  33. Tests review round, 2026-11-04. A fresh review of the two test files, with no context from the writing. Taken, all nine of the defect group: every example's v2 only state proves a v1 operation does nothing (a v2-only fixture that still wrapped v1 passed everything before); the how-to's runtime claim at the tree's step 2 is tested literally, a widget appended to v1 with an OPTIONS aimed at it in both slots prints v1's from useWidgetOptions() and v2's from useWidgetOptionsForId on the v2 id, in the how-to test file (Brian: it verifies a "this is why" claim, as decision 13 set); the first-tag-only test prints the whole props object; the renamed-in-place test renders String(userName) and expects "undefined" instead of a trailing-trimmed match that '' also passed; the invariant test assigns seen instead of pushing; the v2-only-ignores-v1-option test also asserts a seeded tree; the "v2 option then v1 option" test is dropped (Brian: the invariant test above it configures the same v1 add and asserts v2 saw exactly three foliages, which is already order-sensitive); drift labels carry the markdown line number and duplicate comments are checked once (16 of 31 comment tests were repeats from the <details> files and the step 6 diff); failures print the wanted text via toContainEqual(expect.stringContaining(...)); every fence opener in an example section must be ts, tsx, diff, markdown or text, since a block retagged typescript or indented dropped out of the guard silently. That check found the two commit-message blocks in the rename example untagged; they are tagged text rather than bare fences allowed, so an untagged code block still fails. Two lines of the how-to, amended into the docs commit; the rest into the tests commit. 233 tests. Deferred, Brian's call later: INSERT_BEFORE/INSERT_AFTER/REMOVE and layout OPTIONS untested against "every v1 widget, layout and option"; what a v1 option receives; a v1 option returning foliage still rendering 🪾; a two-tag default content; a fixture file no EXAMPLES entry lists; an empty diff side matching vacuously. Noted, no action: the guard is one-directional by design; the how-to cannot elide with // ... and a fixture cannot reorder declarations or wrap a doc comment without breaking a block, none of which applies today.

  34. The first review round's three undecided items, and the plan's open questions, closed (Brian, 2026-11-04). "Moving a widget to v2" stays after step 6: it is supplemental, the how-to is for people changing slots, not for site operators using them. No sentence on idAliases: the how-to covers the cases where an alias does not fit. The README.v1.md convention and exporting a new type from index.ts stay as written: the README split was done in the FPF era and exporting types is something the apps are going to get better about. Plan open question 1 (DEPR timing at step 5) is not part of this how-to, removal is a feat!; a reviewer may raise it. Open question 2 (props interface naming) stays as the examples do it; a later iteration might alias the latest version to the unversioned name once the old one is gone, not in this PR. Open questions 3, 4, 5 (pre-release apps, a runtime warning, a link from ADR 0009) stay unmentioned: nothing to say about them fits this how-to. Open question 6 is decision 3.

  35. No link from docs/how_tos/migrate-frontend-app.md (Brian, 2026-11-04). The plan had the new how-to linked from that file's "Refactor slots" section; that how-to is about migrating FPF slots and this one is not about migration, so it does not go there. Nothing links to the new how-to; it sits in docs/how_tos/ beside the others.

  36. PR body open question 2 dropped (Brian, 2026-11-04: it felt odd). It stated that renaming an exported type keeps the old name as a @deprecated alias until the app's next breaking release, attributing that to the Decide whether apps export the types their slot props are built from #339 answer. The Decide whether apps export the types their slot props are built from #339 answer is only "Yes. They should export."; the alias rule is Decide whether apps export the types their slot props are built from #339's own open question 2 (shape-only or name-too contract), still undecided there, and where it lives is Decide whether apps export the types their slot props are built from #339's open question 3. The PR body now has one open question, how deprecations are found. The how-to says nothing about renaming an exported type.

  37. Two rename examples are added after the tree list, alias case first (Brian, 2026-11-07). Trigger: refactor: rename course catalog data to catalog data [FC-0137] frontend-app-catalog#174's author noted the how-to has no case for deprecating one slot in favour of another (refactor: rename course catalog data to catalog data [FC-0137] frontend-app-catalog#174 (comment)), and the review there had sent four pure renames down the wrapping path when slot idAliases (feat: support slot ID aliases #178) is the lighter mechanism for a rename with the contract unchanged. One domain for both so the contrast is the point: a music app's player page with a songList slot (songs: Song[]) and a songControls slot (onPlay, onPause); podcast episodes are being added beside the songs. "Renaming a slot": songControls becomes trackControls, props, place and content unchanged, so ADR 0009 section 2's contract is untouched; the component, folder and props interface are renamed with the old interface name kept as a deprecated type alias; the one <Slot> gains idAliases={['...songControls.v1']}; one README, retitled, with a note that the old id is a deprecated alias, and the slots index listing both ids; feat: naming the deprecated id; the alias removed later as feat!:. Fixtures: before, aliased, alias dropped; tests: an old-id widget renders in the aliased slot and reads the same props, and does not render once the alias is gone. "Renaming a slot when its props change": songList becomes trackList with tracks: Track[], a track being a song or an episode; the content type widened, so it is a contract change, and the identifier would mislead as songList.v2, so it is a new identifier, not a version (ADR 0009 section 2, "if it still make sense to use the same Identifier"). Opens with one what-not-to-do diff, the alias, which hands an old-id widget songs as undefined, then the prop-rename example's six steps with the differences named: the new interface is unversioned and the old one deprecated whole; trackList.v1 wraps songList.v1 and passes it tracks.filter(isSong), which meets the old contract exactly; the notes name the new id; the commits name slots rather than versions. Fixtures: the three states plus the alias what-not-to-do; tests: the old-id widget gets only the songs, the new-id widget every track, the alias state fails the old widget, the removal state ignores old-id operations. The title stays; the overview gains one sentence saying the last two examples are renames, with and without a contract change. Decision 34's "no sentence on idAliases" is superseded: the alias example is that sentence.

  38. The old README is named for the old slot, minus the parts it shares with the current one (Brian, 2026-11-07). The greeting case shares the identifier, so README.v1.md as before; the track list shares nothing, so README.songList.v1.md. Brian's check: a slot on v3 renamed while its README.v2.md is still in the folder keeps both files distinct. Rejected: README.deprecated.md (the catalog author's choice in refactor: rename course catalog data to catalog data [FC-0137] frontend-app-catalog#174 at 1e1c048994) names a state, not a slot, and two deprecated READMEs in one folder would be named one by state and one by version; README.songList.md drops the version, so it is not the old id either, and its rule ("whatever differs") has to be worked out. Also weighed and set aside: the old folder keeping its README.md beside the new one, one folder per slot, honest about "two slots" but an empty folder for the whole deprecation window.

  39. Decisions 37 and 38 written, 2026-11-07. Fixtures SongControlsSlot.tsx, TrackControlsSlotNoAlias.tsx, TrackControlsSlotAliased.tsx, TrackControlsSlot.tsx; SongListSlot.tsx, TrackListSlotAliased.tsx, TrackListSlotWrappingSongList.tsx, TrackListSlot.tsx. The alias example's steps are its own five (rename the component and props interface; keep the old id as an alias; update the README; feat:; remove the alias), since nothing wraps; the wrapped rename reuses the new-version headings with "slot" for "version". Track is one interface with kind: 'song' | 'episode' rather than a union, so the old slot's songs: Song[] is tracks.filter(track => track.kind === 'song') with no type guard (Track is structurally a Song); Song goes at removal and the feat!: names it. The test matrix (describeExample) gained an optional names pair so its test names read songList/trackList for the wrapped rename instead of v1/v2; the alias example has its own cases (a widget under either id gets the same props, checked by clicking it and asserting the app's onPlay ran once; a replacement under either id replaces the one default content; the old id does nothing without the alias and after its removal). The alias what-not-to-do for the wrapped rename is tested like the greeting's rename-in-place: @ts-expect-error on a widget reading songs from the aliased slot's props, and String(songs) renders "undefined". Results: npm run test -- runtime/slots 288 passed, npm run typecheck clean, npm run lint clean through all three stages once the test-site was set up the way ci.yml does it (npm run pack, then npm i ../pack/openedx-frontend-base.tgz and npm ci in test-site); the test-site stage runs this repo's own openedx lint, built into dist/tools/cli, and had never been installed in this checkout, so earlier runs stopped there. The npm i rewrites test-site/package-lock.json with unrelated bumps and is restored afterwards; pack/ and test-site/node_modules are ignored. Both commits rebuilt on 72d7053 with the bodies naming six examples; nothing pushed, PR body not yet updated.

  40. Step 2 of the alias example is the heading and the code, no prose after (Brian, 2026-11-07). The sentence explaining what an alias does ("An operation whose slotId is an alias applies to the slot as if it named the slot's id ...") was cut: the heading says "Keep the old id as an alias", the code shows idAliases with the old id, and the tests carry the consequences. Commits rebuilt again on 72d7053; the two how-to suites pass (229).

  41. The alias example's README step is one sentence and the note; no slots-index sentence in either rename example (Brian, 2026-11-07). Step 3 now reads "Change the slot id in the README's title and examples, and add a note at the top:" with the note in the greeting note's shape (no "until then it keeps working as an alias" clause). The "If the app lists its slots in one place ..." sentence was cut from both rename examples: the four version examples never mention an index, and an app that has one updates it without being told. The step 4 commit body lost the same clause ("; until then the old id keeps working as an alias") and now ends "instead." like the greeting's. Supersedes that part of decision 37. Commits rebuilt on 72d7053; the two how-to suites pass (229).

  42. The wrapped rename's opener states the change and the alias failure, nothing on versions (Brian, 2026-11-07, after two drafts that argued identifier-over-version with ADR 0009 cited mid-example). What the rejected drafts shared: they classified the change, which decision 11 moved out of this how-to, and passed a verdict on the alias in prose where the other openers let the wrong move's diff and its concrete failure carry it. Now: "Podcast episodes are being added to the list, so songs is becoming tracks, a list of songs and episodes, and the slot is becoming trackList. Keeping the old id as an alias, as in the previous example, would give a widget that reads songs undefined." then the alias diff, then "The steps below add the new slot while leaving the old one in place. They are the steps that add a new version, with the new slot in v2's place and the old slot in v1's." Brian: no sentence on not incrementing the version, since the example is titled "renaming". Commits rebuilt on 72d7053; the two how-to suites pass (229).

  43. The wrapped rename opens with the in-place diff, and the alias what-not-to-do is gone (Brian, 2026-11-07). Opener: "Podcast episodes are being added to the list, so both the slot id and its props are changing:" then the diff from SongListSlot to the final TrackListSlot (both sides fixtures already), then "Making the change in place removes songList.v1 from the app, which is a breaking change to the app.", the greeting's bump-in-place sentence. Brian's reason for dropping the alias diff: the greeting's two "don't" diffs are the intended change done naively, where the alias diff showed adding something nobody intended. Nothing in the example now mentions aliases; the contrast with the previous example is carried by the two headings. TrackListSlotAliased.tsx, its drift entry and the "aliased instead" test are removed; the tests commit body no longer names them. Supersedes those parts of decisions 37, 39 and 42. Results: npm run test -- runtime/slots 287 passed, npm run typecheck clean, npm run lint clean through the test-site stage. Commits rebuilt on 72d7053; tree clean.

  44. The wrapped rename's opener is the change sentence and the diff, then step 1 (Brian, 2026-11-07). Brian's sentence: "Podcast episodes are being added to the list. The slot's id and props are changing:". The two paragraphs after the diff (the greeting's "removes ... from the app, which is a breaking change" line, and "The steps below add the new slot ... with the new slot in v2's place and the old slot in v1's") are dropped: the diff is the change itself, not a wrong move, so there is nothing to warn about, and the parallel step headings say the mapping; the banner and list examples go from their change sentence straight to step 1 the same way. Commits rebuilt on 72d7053; the two how-to suites pass.

  45. The wrapped rename's step 1 prose is the Track export sentence only (Brian, 2026-11-07). "The old interface keeps its name and every member it had. The two slots have different identifiers, so their props interfaces already have different names, and nothing needs a versioned name." said what the heading and code show; the surviving sentence states the one fact the code cannot, as the banner's step 1 does for Notice, and names TrackKind beside Track (Brian: it is new too). Commits rebuilt on 72d7053.

  46. The wrapped rename's step 2 is one imperative sentence, nothing after the code (Brian, 2026-11-07: the prose was "x does y" narration, the fault decision 17 names). Now: "Rename the component, its file and its folder with the slot, and wrap the old <Slot> in the new one as v2 wraps v1 in the rename." The paragraph after the code that restated the first example's three "what keeps both working" bullets is cut. Commits rebuilt on 72d7053.

  47. Step 2's trailing clause is cut (Brian, 2026-11-07: "as v2 wraps v1 in the rename" made no sense). It was meant to point at the version examples, where v2 wraps v1, and the tail was wrong. The sentence now ends at "wrap the old <Slot> in the new one."; the step headings already parallel the version examples, so no pointer replaces it. Commits rebuilt on 72d7053 (docs 4b1b272, tests aa531a4); the two how-to suites pass (228).

  48. Every step 3 is "Mark the old props as deprecated" (Brian, 2026-11-07). The four version examples said "Mark the old version as deprecated" and the wrapped rename "Mark the old slot as deprecated", but the step adds @deprecated to the props interface, its members and (in the tree example) the tree type; nothing marks a slot or a version, and frontend-base has no way to tag a slot id as deprecated. The heading now names what the step does. Considered and set aside: a step that marks the slot itself, which would mean a JSX comment and nested steps, a bigger rework than the gap is worth, and the README note already says the old slot is deprecated. Extends decision 29 (@deprecated on exported types and props only). Commits rebuilt on 72d7053 (docs c85b679, tests c784e75); the two how-to suites pass (228).

  49. The wrapped rename's README step is "Same as for the rename" plus the old file's name (Brian, 2026-11-07: the paragraph was explaining the README naming rule inline). Now: "Same as for the rename, with the old slot's README.md renamed to README.songList.v1.md. The note at its top:" then the note. The rule itself stays in decision 38; the three version examples already say "Same as for the rename." and this one differs only in the old file's name. Commits rebuilt on 72d7053 (docs cd3d6e9, tests 754e437); the two how-to suites pass (228).

  50. "The rename" is "the prop rename" throughout (Brian, 2026-11-07: with two slot-rename examples in the file, "Same as for the rename" reads as the slot rename, and the wrapped rename renames both). All twelve cross-references to the first example (nine "Same as for the rename" steps, two "@deprecated notes as in the rename", the tree removal's "Same as for the rename, and everything v1 needed goes:") now say "the prop rename". Commits rebuilt on 72d7053 (docs a74bb05, tests c3bcb5d); the two how-to suites pass (228).

  51. The feat!: lead-in mirrors the feat: one (Brian, 2026-11-07: "This is the breaking change to the app, and the commit says so:" read badly). The three removal steps (prop rename, alias, wrapped rename) now say "Removing the old version / the alias / the old slot is a breaking change to the app, so this commit is feat!:.", the shape of each step 5's "...is not a breaking change to the app, so this is feat: and not feat!:." The subject in each is the removal heading's object. Commits rebuilt on 72d7053 (docs 314529c, tests 079dc92); the two how-to suites pass (228).

🤖 Generated with Claude Code

brian-smith-tcril added a commit to brian-smith-tcril/bairf that referenced this pull request Nov 4, 2026
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@brian-smith-tcril

brian-smith-tcril commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor Author

Answer to open question: #345

brian-smith-tcril and others added 2 commits November 7, 2026 17:25
Walks through ADR 0009's new-version path on four invented examples:
a prop rename, in full (v2 wrapping v1, deprecation notes, a README per
version, the feat: commit, the later removal); a prop type change under
the same step headings, showing only what differs; a prop that becomes
a list, where the old prop has no direct source in the new one and the
list is added to v1 as well; and a slot whose default widget takes
widget options, where the item type gains a required field and the
widget serves v1 and v2 operators' options at once until v1 is removed.

Two further examples rename a slot. With its contract unchanged, the
old id is kept as an alias of the one slot until it is removed. With
its props changed, ADR 0009 calls for a new identifier rather than a
version, so a new slot wraps the old one as a new version would, and
the old README is named for the old slot (README.songList.v1.md), the
rule that also names a previous version's README.v1.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Fixtures under runtime/slots/testMocks/ hold each example slot from
docs/how_tos/add-a-slot-version.md in the three states the how-to
shows: v1 only, v2 wrapping v1, and v2 after removal. For the rename
example they also hold the two states the how-to says not to produce,
and the operator's widget before and after its move to v2; for the
list example, the state before the list is added to v1; for the
widget-options example, the operator's OPTIONS operation and the page
that renders the slot before and after the change. For the two
slot-rename examples they hold the slot before, with the old id as an
alias or the new slot wrapping the old one, and after the alias or the
old slot is removed, plus, for the alias, the id renamed with no alias.

howToExamples.test.tsx renders each state the ways an operator can use
it: component widgets against either version, replacing the default
content, replacing the layout and reading the props through
useSlotContext(), and widgets appended to both versions at once. It
checks that a v2 replacement of the default content takes v1 and its
widgets with it and that a v2 layout keeps them, which the how-to's
step 2 states, and it verifies the how-to's reasons for not renaming
or bumping in place: a widget reading the old prop gets undefined, the
typecheck rejects it, and a widget on the old slot id stops rendering.
For the list example it checks what a v1 widget gets with two tags
selected, before and after the list is added to v1, and with none.
For the widget-options example it checks that a v1 option reaches the
default widget on v1 only and wrapped, that a v2 option reaches it
wrapped and on v2 only, that a v1 widget gets trees without the new
field, that a tree a v1 option renamed keeps its foliage, and that a
v2 option's function never receives a tree a v1 option added. For the
aliased rename it checks that a widget under either id gets the same
props and that a replacement under either id replaces the one default
content, that a widget under the old id stops rendering without the
alias and after its removal. The wrapped rename runs the same matrix as
a new version, with the old and new slot in v1's and v2's place, and
checks that an old-slot widget gets only the songs.

howToExamples.drift.test.ts reads the how-to and asserts that every ts
or tsx block under an example heading, and both sides of every diff
block, appears in one of that example's fixtures, with each comment
line checked verbatim, so the markdown and the tested code cannot
drift apart silently.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Write a slot best practices how-to

1 participant