Page MenuHomePhabricator

[Timebox] Lookup: Issues with props reactiveness
Closed, ResolvedPublic2 Estimated Story PointsBUG REPORT

Description

Summary

The Lookup component's initialInputValue prop was initially added as a workaround to avoid adding a v-model prop for the input value. Most dev users of Lookup don't need to read or write the input value, so it is just tracked inside Lookup, and initialInputValue was added as a way to set only the initial value if needed.

However, there is now a Wikifunctions use case for setting an initial input value that is later updated once more data about the initially selected item is fetched. They're getting around this by tricking the Lookup into re-rendering via a key, but ideally they could use a reactive prop as is supported by other components.

Original report

About initialInputValue

We use the cdx-lookup selector to search and select objects which have a "Zid" as their value, but we present them using the human-readable labels for the selected user language or closest fallback. When we edit a page, we use this selector to initially present the current value:

E.g. as seen in beta edit page for Z11:

Screenshot from 2024-05-22 10-06-10.png (357×364 px, 14 KB)

The original values here are, however, the values in their Zid form. The cdx-lookup fields are rendered passing the available label to the initialInputValue prop. Initially the labels are not available, as they are being fetched on demand, so these reactive getters return the Zids while they are waiting for the labels to return:

Screenshot from 2024-05-22 10-10-17.png (374×415 px, 11 KB)

When the labels are made available and the getter that generates the initialInputValue returns a human-readable label, the cdx-lookup component doesn't detect the change, so we need to re-render the component:

We do this with a bit of a hack:

  • have a :key="lookupKey" property in the component
  • watch changes in selectedLabel
  • when the label changes (e.g. from "Z4" to "Type", as it becomes available in the store), we increment lookupKey to force a re-render
<cdx-lookup
  :key="lookupKey"    <-------------------------- key to force re-render
  :selected="selectedValue"
  :disabled="disabled"
  :placeholder="lookupPlaceholder"
  :menu-items="lookupResults"
  :menu-config="lookupConfig"
  :end-icon="lookupIcon"
  :initial-input-value="selectedLabel"  <---------------- watch changes of selectedLabel
  :status="errorLookupStatus"
  data-testid="z-object-selector-lookup"
  @update:selected="onSelect"
  @input="onInput"
>
  <template #no-results>
    {{ $i18n( 'wikilambda-zobjectselector-no-results' ).text() }}
  </template>
</cdx-lookup>
watch: {
  selectedLabel: function () {
    // Trigger a rerender when initial input value changes,
    // This might occur due to slow network request for a particular label
    // Also make sure not to trigger rerender if the user has typed an input
    if ( !this.inputValue ) {
      this.lookupKey += 1;
    }
  },
  ...
}

About menuItems:

The issue is related to what I described above for initialInputValue: we use the lookup selector to search and select ZObjects, where their value is their Zid and their label is fetched asynchronously.

This works well on other instances of CdxMenu, the labels are shown as soon as they are available. But the Lookup selector menu only updates when the whole menuItems reference changes, but is not reactive to the menuItems values.

Here's a little demo that might make it a bit clearer:

https://www.loom.com/share/58b3694134c7482e9022d8ef2c14129f

Solution

We recently added a composable that enables a component to have an optional prop that is bound by v-model. We can use this inside Lookup to add a new inputValue prop that can be used to give the parent component control over reading and writing the input value. This can replace the initialInputValue prop, which can be deprecated and will be removed in Codex 2.0.


Acceptance criteria

  • Lookup has a new optional prop, inputValue, that is bound via v-model
  • The inputValue is used if provided, otherwise the Lookup will create an internal ref that defaults to the initialInputValue if provided or an empty string if not
  • initialInputValue is deprecated
  • A new demo is added to show how to set an initial selection and input value

Event Timeline

CCiufo-WMF changed the subtype of this task from "Task" to "Bug Report".
CCiufo-WMF moved this task from Inbox to Triaging on the Design-System-Team board.
CCiufo-WMF subscribed.

This sounds more like a bug. I'll discuss with the team.

@AAlhazwani-WMF when you have a moment, could you provide more info about this issue (steps to reproduce, links to an example, screen recording, etc)?

@AAlhazwani-WMF when you have a moment, could you provide more info about this issue (steps to reproduce, links to an example, screen recording, etc)?

pinging @gengh as she can give a much better info than myself :)

Thank you, Amin! I'll try to explain our use cases.

About initialInputValue

We use the cdx-lookup selector to search and select objects which have a "Zid" as their value, but we present them using the human-readable labels for the selected user language or closest fallback. When we edit a page, we use this selector to initially present the current value:

E.g. as seen in beta edit page for Z11:

Screenshot from 2024-05-22 10-06-10.png (357×364 px, 14 KB)

The original values here are, however, the values in their Zid form. The cdx-lookup fields are rendered passing the available label to the initialInputValue prop. Initially the labels are not available, as they are being fetched on demand, so these reactive getters return the Zids while they are waiting for the labels to return:

Screenshot from 2024-05-22 10-10-17.png (374×415 px, 11 KB)

When the labels are made available and the getter that generates the initialInputValue returns a human-readable label, the cdx-lookup component doesn't detect the change, so we need to re-render the component:

We do this with a bit of a hack:

  • have a :key="lookupKey" property in the component
  • watch changes in selectedLabel
  • when the label changes (e.g. from "Z4" to "Type", as it becomes available in the store), we increment lookupKey to force a re-render
<cdx-lookup
  :key="lookupKey"    <-------------------------- key to force re-render
  :selected="selectedValue"
  :disabled="disabled"
  :placeholder="lookupPlaceholder"
  :menu-items="lookupResults"
  :menu-config="lookupConfig"
  :end-icon="lookupIcon"
  :initial-input-value="selectedLabel"  <---------------- watch changes of selectedLabel
  :status="errorLookupStatus"
  data-testid="z-object-selector-lookup"
  @update:selected="onSelect"
  @input="onInput"
>
  <template #no-results>
    {{ $i18n( 'wikilambda-zobjectselector-no-results' ).text() }}
  </template>
</cdx-lookup>
watch: {
  selectedLabel: function () {
    // Trigger a rerender when initial input value changes,
    // This might occur due to slow network request for a particular label
    // Also make sure not to trigger rerender if the user has typed an input
    if ( !this.inputValue ) {
      this.lookupKey += 1;
    }
  },
  ...
}

About menuItems:

The issue is related to what I described above for initialInputValue: we use the lookup selector to search and select ZObjects, where their value is their Zid and their label is fetched asynchronously.

This works well on other instances of CdxMenu, the labels are shown as soon as they are available. But the Lookup selector menu only updates when the whole menuItems reference changes, but is not reactive to the menuItems values.

Here's a little demo that might make it a bit clearer:

https://www.loom.com/share/58b3694134c7482e9022d8ef2c14129f

@CCiufo-WMF do you need other info on our end? if not, is this something that the codex team could/will work in the near future? mostly asking because we still have this issue on wikifunctions, and we'd like to understand if we should implement a temporary fix locally, thank you so much as always!

CCiufo-WMF renamed this task from Lookup: props reactiveness to Lookup: Issues with props reactiveness.Jul 11 2024, 4:27 PM
CCiufo-WMF updated the task description. (Show Details)
CCiufo-WMF set the point value for this task to 3.Jul 11 2024, 5:24 PM

@AAlhazwani-WMF I'm just seeing these updates now, sorry about that! We'll try to take a look at this in our next sprint.

CCiufo-WMF renamed this task from Lookup: Issues with props reactiveness to [Timebox] Lookup: Issues with props reactiveness.Jul 11 2024, 5:24 PM
CCiufo-WMF triaged this task as Medium priority.
CCiufo-WMF moved this task from Triaging to Up Next on the Design-System-Team board.

@AAlhazwani-WMF Could you (or someone on your team) point us to where in the WikiLambda code these things happen? We're particularly interested in where the initialInputValue and menuItems props are bound and where those values are generated and changed.

You can see all this happening in our base/ZObjectSelector.vue component

Particularly:

  • handle initialInputValue changes: selectedLabel watcher in line 466
  • set the menu items suggested items from the demo example: line 428
  • the button menu shown in the demo (the example which works) is in ModeSelector.vue: line 123 is where we set the menu items. This is a computed property, but my attempts to use a computed property to pass onto the CdxLookup menuItems prop have never worked, not sure why.
  • the getter that returns the labels as soon as they are available is in our library.js Vuex module: getLabelData in line 255
		setLabel: function ( state, labelData ) {
			setTimeout( () => {
				state.labels[ labelData.zid ] = labelData;
			}, 5000 );
		},

Notes from my initial look at this in July:

  • I think the menuItems reactivity issue might be because Menu sets a shallow watcher on menuItems, not a deep watcher. We could either fix this by setting a deep watcher, or we could document that reactivity for modifying individual menu items in-place (meaning, modifying one of the individual MenuItemData objects without modifying the overall menuItems array) is not supported and a computed ref must be used.
  • initialInputValue is not reactive by design, because of the "initial" part of its name. We could consider supporting reactivity for this prop but I think that would be tricky, because we wouldn't want a change to initialInputValue to have any effect if the user has already modified the value of the input

@Catrope do you think an optional modelValue prop for the input value would be a good solution for the initialInputValue part? That way, the parent component could set the input value whenever it wanted to, but it could still be updated via user interaction. If so, we could even deprecate initialInputValue and remove it in 2.0

Another potential solution to investigate would be to ensure that, when there's an initial selection, its label is displayed in the input.

Follow-up on our Lookup discussion yesterday: unfortunately, I think our plan of requiring users to pass in a matching menu item when there's an initial selection will not be sufficient with the way it's currently being used. The Abstract Wikipedia use case actually does have an initial selection paired with initial suggestions, so they won't be able to have a menu item matching the selection. And there are instances in other codebases where menuItems is always initialized to an empty array even if there might be an initial selection.

I think the best solution to this problem is to change the selected prop to take in a MenuItemData object instead of a single value (or accept both, i.e. you can provide only a value if the menu items don't have labels). That way, Lookup will not need to look through menuItems to find one that matches the selected value in order to find out whether the menu item has a label - it will have all the data it needs from the selected prop.

I've outlined 2 ways we could get to this point. Some definitions:

  1. Current behavior: selected is a value and Lookup must find a matching menu item to see if there's a label.
  2. New behavior: selected is a value only if there is no label. Otherwise, it's a MenuItemData object. Either way, only the selected prop is used; Lookup does not try to find a matching menu item.

Option 0: don't do this

  • Add an optional v-model:input-value prop that can be used instead of initialInputValue to resolve this task (and probably deprecate initialInputValue)
  • Update the code in the input handler to also clear the selection if there is no matching menu item and props.selected !== newVal, resolving T370504

Option 1: make a non-breaking change now

Now:

  • Update the selected prop now to take in what it currently does, plus a MenuItemData object
  • Update the code throughout Lookup to account for both the current and new behaviors. If selected is an object, it look for a label there. If not, it looks for a matching menu item.
  • Update Lookup to look at the initial selection and try to derive what the input value should be from it
  • Deprecate initialInputValue

AW could start using the new selected prop now and it would resolve this task. It could also be used to resolve T370504.

For Codex 2.0:

  • Update the selected prop to require the new behavior, and remove all the support for the old behavior from Lookup.
  • Remove initialInputValue (possibly replacing with optional v-model:input-value if there's a use case)

Option 2: add inputValue now, wait for 2.0 to change the selected prop

Now:

  • Add an optional v-model:input-value prop like we did with ChipInput and deprecate initialInputValue. This will allow the AW team to update the initial input value when more data is fetched, resolving this task.
  • Go ahead and add code that looks at the initial selection and tries to find a matching menu item so it can set the input value. If none is found, do nothing. If there's an inputValue or initialInputValue, allow those to override it.

For Codex 2.0:

  • Change the selected prop to take in a MenuItemData instead of a single value (or accept both), following the new behavior.
  • Remove initialInputValue
  • Consider removing v-model:input-value, unless there's a use case for it

My thoughts:

  • Option 0 is easy and lower-risk but doesn't really resolve the actual problem IMO - changing the selected prop would simplify the Lookup code and API, and save us some headaches in the future.
  • Option 1 is a lot cleaner from a dev user standpoint and makes a lot of sense. However, it will make the Codex 1.0 Lookup code even more complex. We'd need to carefully cover this with tests and even then I worry about maintaining this code for very long, or causing regressions.
  • Option 2 is a lot simpler from a Codex dev standpoint, and would be quicker to roll out. Making the breaking change to selected all at once would be a lot simpler. However, we'd need separate solutions for this task and T370504, and we'd be adding a new prop (inputValue) that we might not even keep with 2.0. Plus, we'd probably want to make a non-breaking change to selected at some point before 2.0, right? So maybe we should just do it now.

What do others think?

Option 0: don't do this

  • Add an optional v-model:input-value prop that can be used instead of initialInputValue to resolve this task (and probably deprecate initialInputValue)
  • Update the code in the input handler to also clear the selection if there is no matching menu item and props.selected !== newVal, resolving T370504

This would be the approach I'd recommend for now. If the input value is important on its own, then users should bind it to something in the parent. If v-model:inputValue and initialInputValue are both provided and do not agree, then inputValue should take precedence IMO. We could update the docs to make the component's usage more clear too.

Marking initialInputValue as deprecated sounds good to me as well. We may need to update our presentation of component props on the docs site to clearly indicate the deprecated status. We've been thinking about Codex 2.0 anyway, so it makes sense to start marking things we want to change in this way.

For Codex 2.0:

  • Change the selected prop to take in a MenuItemData instead of a single value (or accept both), following the new behavior.
  • Remove initialInputValue
  • Consider removing v-model:input-value, unless there's a use case for it

I think we may need to continue to support Lookup usage where both the input value and the selection value are important (and can potentially diverge). I'm thinking of say a page where the initial input is populated by a URL query paramater (?search=foo) and the menu items come from a different API request (meaning that they won't be available until after a delay, and they may not contain any matching item at all).

I don't love the idea of using a rich object for 2-way binding with v-model (I feel like there may be reactivity gotchas lurking here) but this is something we can talk about. JS objects are just too slippery to pin down (who knows what instance properties or methods may have been added at runtime, etc); for something as important as a component form value, I'd prefer to stick to immutable primitives.

Either way, this seems like something that we can finalize as we approach a 2.0 release of Codex.

For now introducing a new inputValue prop, updating the usage docs, and marking initialInputValue as deprecated seems like a good solution.

I agree with Eric, I prefer option 0. I don't think making the selection a full MenuItem object makes sense.

Thanks y'all - I'll go with option 0 to resolve these tasks

I don't love the idea of using a rich object for 2-way binding with v-model (I feel like there may be reactivity gotchas lurking here) but this is something we can talk about. JS objects are just too slippery to pin down (who knows what instance properties or methods may have been added at runtime, etc); for something as important as a component form value, I'd prefer to stick to immutable primitives.

That's a good point. I was also thinking more about it and realized that we wouldn't want to make the selected prop inconsistent between the menu components. I can still see some value in doing that, but it's probably not worth it at this point.

@AnneT can you update the acceptance criteria here (assuming that we want to get the initial stage of this fix in during this sprint)? Thanks for filing the follow-up task too.

Change #1068071 had a related patch set uploaded (by Anne Tomasevich; author: Anne Tomasevich):

[design/codex@main] Lookup: Add optional inputValue and deprecate initialInputValue

https://gerrit.wikimedia.org/r/1068071

Change #1068071 merged by jenkins-bot:

[design/codex@main] Lookup: Add optional inputValue and deprecate initialInputValue

https://gerrit.wikimedia.org/r/1068071

Change #1070656 had a related patch set uploaded (by Eric Gardner; author: Eric Gardner):

[mediawiki/core@master] Update Codex from v1.11.1 to v1.12.0

https://gerrit.wikimedia.org/r/1070656

Test wiki created on Patch demo by EGardner (WMF) using patch(es) linked to this task:
http://patchdemo.wmcloud.org/wikis/6bc5837bc4/w/

Change #1070656 merged by jenkins-bot:

[mediawiki/core@master] Update Codex from v1.11.1 to v1.12.0

https://gerrit.wikimedia.org/r/1070656