Repository navigation
docs: add how-to for adding a new version of a slot - #344
Open
brian-smith-tcril wants to merge 2 commits into
Open
brian-smith-tcril wants to merge 2 commits into
brian-smith-tcril wants to merge 2 commits into
Conversation
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>
Contributor
Author
|
Answer to open question: #345 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:v2wrappingv1,@deprecatednotes, a README per version, thefeat:commit, the laterfeat!:removal, and a closing section on moving an operator's widget tov2. 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 tov1as well. A slot whose default widget takes options, where the item type gains a required field and the widget servesv1andv2operators' options at once untilv1is 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 (v1only,v2wrappingv1,v2after 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 tov1, 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 throughuseSlotContext(), widgets on both versions at once, av1operation on thev2-only state) and checks the reasons the how-to gives: a widget reading a renamed-in-place prop getsundefinedand the typecheck rejects it, a widget on a bumped-in-place id stops rendering, av1widget gets the first tag or an empty string, av1option reaches the default widget only whilev1exists, av2option's function never sees a tree av1option added, anduseWidgetOptions()inside the wrappedv1readsv1's options whileuseWidgetOptionsForIdon thev2id readsv2's.runtime/slots/howToExamples.drift.test.ts: reads the how-to and asserts that everytsortsxblock under an example heading, and both sides of everydiffblock, 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 aBREAKING CHANGE:footer is easy to search for, but thefeat:commit that addsv2and deprecatesv1is 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 typecheckandnpm run lintclean.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
v2wrapsv1. The how-to prescribes it and does not describe siblings orv1-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.@deprecatednotes 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.GreetingSlotV1Props,GreetingSlotV2Props), with the unversioned name kept as a deprecated alias ofv1untilv1is 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.stable, not when it lands onmain. Brian, 2026-11-03:mainpublishes alpha prereleases (.releaserc:main→ channelalpha,stable→latest), so there is no ticket to link at commit time. TheREADME.v1.mdwarning 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.docs/how_tos/add-a-slot-version.md, Markdown, title "How to: Add a New Version of a Slot". The first draft wasslot-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.README.mdis the current version and links to the previous one,README.v1.mdis 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 (authoringCourseAuthoringUnitSidebarSlot/README.md+README.v1.mdin refactor: CourseAuthoringUnitSidebarSlot frontend-app-authoring#2000; headerv1/README.md+v2/README.mdsubfolders 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.runtime/slots, notdocs/(Brian, 2026-11-03). Jest's projects aretools,runtime,shell, andtsconfig.jsonincludes onlyruntime/**andshell/**, so nothing underdocs/runs or type-checks. Fixtures live inruntime/slots/testMocks/becausetsconfig.build.jsonexcludes**/testMocks/**from the published build (precedent:runtime/testMocks/,shell/testMocks/). One fixture per state the markdown shows (three per example).howToExamples.test.tsxruns the operator-usage matrix from the plan;howToExamples.drift.test.tsasserts everytsxblock under an## Example:heading is a verbatim substring of one of that example's fixtures, ignoring import lines (fixtures importSlotby relative path; no@openedx/frontend-baseself-mapping exists) and doc-comment lines (the how-to shows the interfaces with and without@deprecatednotes), with each@deprecatedline checked separately. Results at commit time:npm run test -- runtime/slots111 passed;npm run typecheckclean, including widgets typed({ userName }: GreetingSlotProps)with no cast, so the how-to's operator diff compiles under the repo's config;eslint .andlint:toolsclean; the test-site lint step needs a builtdist/(openedxCLI) and was not run.v2layout "replacesv1and everything in it" contradicteduseWidgets()(a replacement layout still receivesdefaultContent, the innerv1slot) and the test "keeps v1 and its widgets inside a replaced v2 layout"; it is now its own bullet saying the layout receivesv1as itsdefaultContentwidget. "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 everydiffblock, not onlytsxfences: 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 readinguserNameundefined and the typecheck rejects that widget (one test,@ts-expect-erroron 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 av1widget; the operator's widget replaces the default content on the states each side of the diff applies to. The fouruseSlotContext() as unknown as Xcasts became oneuseSlotProps<Props>()helper; Brian accepts it in a test only. Nothing in the runtime gives a layout a typed read of the slot's props (SlotContextis its fixed members plus an index signature; the dev showcase narrows withtypeof); 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 toGreetingSlotV1Props" (true, one hop more); no sentence on theBannerstand-in's own props changing (it exists only for the tests). Deferred: anOPTIONStest to the widget-options example, since neither example takes options. Not decided: "Moving a widget tov2" sitting after step 6, anidAliasessteer, the README andindex.tsconventions being stated without repo precedent.TaggableItemSlot, Brian's name over "filter")selectedTag: stringbecomesselectedTags: string[]. The nearestv1value,selectedTags[0], loses the rest of the list, sov1gainsselectedTagsas well (adding a member does not break the contract) andselectedTagis deprecated withinv1pointing atselectedTags;v2isv1without the old member. Brian's reasoning: av1widget only sees the inner slot's props, so adding the list tov1is 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.mddocuments the new prop). The banner could have done the same (v1gainingnotice) but itsmessageis 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. FixturesTaggableItemSlotV1.tsx,TaggableItemSlotV1V2.tsx,TaggableItemSlotV2.tsx; the usage matrix plus one test that av1widget 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/slots158 passed, typecheck clean,eslint .andlint:toolsclean.selectedTagscan also be added tov1as 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 thatv1receivesselectedTags[0]; Brian spotted that nothing in step 1 says the first entry of the list isselectedTag. 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 "selectedTagscan also be added tov1as a non-breaking change" with a diff adding it toTaggableItemSlotV1Props. The two rejected shapes were one block holding both interfaces (thev1part did not belong in a block meant to showv2) and a diff againstTaggableItemSlotV1Propswith no block declaring it (it assumedv1existed when it did not yet). The state after the first two blocks,v2wrappingv1with only the first tag passed down, is the fixtureTaggableItemSlotV1V2FirstTagOnly.tsx, with one behaviour test: av1widget sees only the first tag when two are selected. The claim test (av1widget gets the first tag under the old name and the whole list under the new one) runs onv2wrappingv1. Results:npm run test -- runtime/slots162 passed, typecheck clean,eslint .andlint:toolsclean.v1gets 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." / "selectedTagscan also be added tov1so widgets in thev1slot 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. Herev1expects a single tag andv2has 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]isundefinedagainst astringcontract; the repo does not enablenoUncheckedIndexedAccess, so the typecheck is silent); of falling back to'', not renderingv1when 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@deprecatedlines, so the comment is guarded. One test added: with an empty list onv2wrappingv1, av1widget readingselectedTagrenders""(asserted throughJSON.stringify, which tells''fromundefined). Results:npm run test -- runtime/slots164 passed, typecheck clean,eslint .andlint:toolsclean.@deprecatednotes as in the rename."; wordings that spelled out where the member's note points ("selectedTags, whichv1already has") were rejected as awkward and obvious, and "The interface's note points atv2" as not a how-to sentence. The member's note reads "This is the first ofselectedTags, or an empty string when there are none. ReadselectedTagsinstead.", 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 tov1. The redundant paragraph after step 2's code (what av1widget 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 needsGIT_EDITORset on the command, because the session environment setsGIT_EDITOR=true, which outranks-c core.editor.idAliasesis 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 optionalpropAisv1; add optionalpropB, bump the id tov2, keep thev1id as an alias. Both ids are now one slot with one props interface holdingpropAandpropB, so removingpropAbreaksv2as well asv1and needs av3. An alias never lets the contract contract. Wrapping gives each version its own interface, so removing the innerv1is the contraction andv2's contract never grows.idAliasesstays whatSlot's own comment says it is: renaming an id with the contract unchanged. The reviewer's "idAliasesis 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.runtime/slots/widget/hooks.ts,WidgetProvider.tsxandutils.tsx: every widget renders inside aWidgetProvidercarrying its slot's id and its own id;useWidgetOptions()merges theOPTIONSoperations on that slot naming that widget; withv2wrappingv1the default widget isv1'sdefaultContent, andv2'sdefaultContentis thev1<Slot>element, so av2operator'sOPTIONSondefaultContentmatches an element that reads nothing.useWidgetOptionsForId(slotId, widgetId)is exported, so the default widget readsv2's options by id as well, merged withv2winning. Choices taken without Brian (offered, not yet confirmed): the trigger is a plain rename (linkstorelatedLinks) 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 throughuseSlotContext()(the catalog'sDefaultSocialLinksWidgetreads 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: av2operator'sOPTIONSondefaultContentreaches the widget by id before the removal and throughuseWidgetOptions()after, so av2site config does not notice the removal; the usage matrix'sv2 wrapping v1andv2 onlycolumns are the check. The how-to shows the operator'sOPTIONSoperation as atsblock, so the drift test now readstsfences as well astsxand that block has a fixture,relatedLinksOptions.ts. FixturesRelatedLinksSlotV1.tsx,RelatedLinksSlotV1V2.tsx,RelatedLinksSlotV2.tsx. Tests beyond the matrix: the operator'sv1max caps the list onv1 onlyand wrapped (theOPTIONStest deferred in decision 13); av2max caps it wrapped and onv2 only; av2max wins over av1max wrapped; thev1max does nothing onv2 only. Results:npm run test -- runtime/slots197 passed, typecheck clean,eslint .andlint:toolsclean. ADR 0009 does not mention widget options (grepped), so the example does not make the option the reason for the bump.foliage, not related links with amax(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 everyuseWidgetOptions/useLayoutOptionscall in the org (catalogCourseAboutSidebarSocialSlot; frontend-base shellCoursesLink,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 av1operator'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. Fieldfoliage: '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'sv1option adding Quince, the release before Redwood (Brian, same day); Teak and Quince are deciduous, Redwood evergreen. Brian's rendering:v1lists names only;v2prefixes 🌳 or 🌲; for av1-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 plainv2list first, exactly as it will be after removal, thenv1's option on top tov1-shaped trees (foliage stripped going in, restored by id coming out,'unknown'for a tree it added), so av2operator's function never receives'unknown'and thev2contract is the same before and after removal;ShownTreewith'unknown'is the widget's own type and the removal diff deletes it.Treeis versioned (TreeV1,TreeV2, deprecated alias) under the exported-types rule, with the options interfaces local and versioned alongside. FixturesTreeListSlotV1.tsx,TreeListSlotV1V2.tsx,TreeListSlotV2.tsx,treeListOptions.ts(addOliveV1). Tests beyond the matrix: thev1option lists Olive onv1 only, marks it wrapped, is ignored onv2 only; av2option's Olive shows 🌲 wrapped and onv2 only; av1rename keeps the tree's foliage; av2function sees exactly['evergreen', 'deciduous', 'deciduous']with av1option adding Olive (the invariant); av2filter and av1add both apply.defaultContentTextbecamestring | RegExpbecausev1prints "Redwood" andv2"🌲 Redwood". Results:npm run test -- runtime/slots210 passed, typecheck clean,eslint .andlint:toolsclean. Supersedes the related links parts of decision 20. Same day,descriptiondropped fromTree(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."function resolve<T>(option, current)for the array-or-function option; the catalog'sDefaultSocialLinksWidgetresolves it inline with atypeof ... === '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.<TreeListSlot trees={[...]} />block with Teak, Sumac, Redwood; step 2 gains "The app's own list gains the field:" and a diff addingfoliageto each, which is where the required field reaches the app's own code. The default widget isDefaultTreeListWidget, the catalog'sDefaultSocialLinksWidgetpattern in full, becauseDefaultTreesread as "the default trees" when the widget renders the list after options (Brian). FixturesTreeListPageV1.tsxandTreeListPageV2.tsxhold the two renders; two tests render each page and assert the names, plain and with icons. Results:npm run test -- runtime/slots215 passed, typecheck clean,eslint .andlint:toolsclean.TreeV2does not extendTreeV1(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 ofv2.TreeV2declaresid,nameandfoliageitself; only the widget's ownShownTree, which the removal deletes anyway, is built fromTreeV1. General rule for the examples: nothing in av2declaration references av1one. The change sentence is now Brian's: "A new requiredfoliage(evergreenordeciduous) field is being added."ShownTreeand icon map; the two option reads with the one comment that explains them;v2's option applied first;v1's applied tov1-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 ofTreeListSlotV1V2.tsx, which was reordered (old declarations first) to keep that true. Results:npm run test -- runtime/slots217 passed, typecheck clean,eslint .andlint:toolsclean.v2widget and adds thev1parts 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 forv2, as it will be oncev1is gone" with thev2-only widget as atsxblock (a substring ofTreeListSlotV2.tsx); then three diffs whose before sides are that widget and after sides the wrapped one: the two option reads,ShownTreewith 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 thev2-only and wrapped fixtures.'unknown'entry inFOLIAGE_ICONat removal awkward, and named the two moving pieces:v1options have to be adapted for the default widget, which wantsv2trees, andv2's list has to be adapted forv1replacement widgets and layouts, which do not expect foliage. The wrapper now strips foliage before thev1<Slot>(trees={trees.map(toTreeV1)}), the way the list example's wrapper computesselectedTag; the earlier version passedv2trees tov1consumers, a field they were never promised. In the widget, av1option is given the stripped list and each tree it returns gets its foliage looked up by id in thev2list; a tree it added has none.ShownTreeisTreeV1 & { foliage?: Foliage }, the icon map is thev2one untouched, and the render shows whenfoliageis missing. Names say what each list is:v2Trees,withoutFoliage,v1Trees,shown(the earlierafterV2/afterV1said when a value existed).toTreeV1is 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 coverstoTreeV1,ShownTree, the widget and the wrapper. Lead-ins in Brian's register: "This can be worked around by readingv2's options by id" ("Read ... as well" read as a demand; "x does y" rejected). New test: av1widget onv2wrappingv1receives{"id":"teak","name":"Teak"}, no foliage. Results:npm run test -- runtime/slots224 passed, typecheck clean,eslint .andlint:toolsclean.v1handling 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 av1option 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 tov1trees, applies the option, and restores foliage through a small named lookup,const foliageOf = (id: string) => trees.find(tree => tree.id === id)?.foliage;(aMapbuilt from entries was tried first and read oddly to Brian: twomaps and an invisible tuple in one line); the widget body isconst shown = applyV1Option(v1Options.trees, v2Trees);. A namedapplyOptionforv2was offered and rejected: inline makes the removal clearer.TreeListV1Optionis a named type so the function's signature needs no indexed type. The removal diff in step 6 deletestoTreeV1,ShownTree,applyV1Option, thev1read, theshownline, 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/slots227 passed, typecheck clean,eslint .andlint:toolsclean.<details>block, and@deprecatedstays 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 exactlyTreeListSlotV1V2.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 ordinarytsxblock. 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 fromTreeListSlotV2.tsx, placed before the invariant sentence (Brian, same day). Thetreesmember note now names the member: "treesinTreeListSlotV2PropsisTreeV2[]. Av1option cannot set a tree's foliage." Results: drift test 84 passed.v1types, the option types and the import). It is produced withdifflib.unified_diffoverTreeListSlotV1V2.tsxandTreeListSlotV2.tsxwith 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.tsfence support and the removal of the related links files into the tests commit, whose body now describes the options cases. Branch is two commits on72d7053, tree clean, nothing pushed. Still open: the three review items left to Brian (placement of "Moving a widget tov2", anidAliasessentence, the README andindex.tsconventions without repo precedent), the DEPR-ticket step pending the PR-body question, the migration how-to link, and the widened-union example.index.tsbeside 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'stsblock was not under an example heading.v2 onlystate proves av1operation does nothing (av2-only fixture that still wrappedv1passed everything before); the how-to's runtime claim at the tree's step 2 is tested literally, a widget appended tov1with anOPTIONSaimed at it in both slots printsv1's fromuseWidgetOptions()andv2's fromuseWidgetOptionsForIdon thev2id, 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 rendersString(userName)and expects "undefined" instead of a trailing-trimmed match that''also passed; the invariant test assignsseeninstead of pushing; thev2-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 samev1add and assertsv2saw 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 viatoContainEqual(expect.stringContaining(...)); every fence opener in an example section must bets,tsx,diff,markdownortext, since a block retaggedtypescriptor indented dropped out of the guard silently. That check found the two commit-message blocks in the rename example untagged; they are taggedtextrather 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/REMOVEand layoutOPTIONSuntested against "everyv1widget, layout and option"; what av1option receives; av1option returningfoliagestill rendering ; a two-tag default content; a fixture file noEXAMPLESentry 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.v2" stays after step 6: it is supplemental, the how-to is for people changing slots, not for site operators using them. No sentence onidAliases: the how-to covers the cases where an alias does not fit. TheREADME.v1.mdconvention and exporting a new type fromindex.tsstay 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 afeat!; 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.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 indocs/how_tos/beside the others.@deprecatedalias 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