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. 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 four 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.
  • runtime/slots/testMocks/: nineteen 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.
  • 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, 233 tests pass, 182 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.

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.

🤖 Generated with Claude Code

brian-smith-tcril and others added 2 commits November 4, 2026 08:13
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.

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.

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.

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>
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

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