refactor(Example): TabsContainer and StackContainer refactor#3925
refactor(Example): TabsContainer and StackContainer refactor#3925
Conversation
There was a problem hiding this comment.
Pull request overview
Refactors the Gamma TabsContainer and StackContainer to keep reducer/navigation state free of React component references, and instead resolve the latest Component from routeConfigs during render to improve Fast Refresh behavior.
Changes:
- Update
TabRoute/StackRouteruntime types to omitComponentfrom reducer state. - Strip
Componentwhen creating routes in the tabs/stack reducers. - Resolve the correct
Componentper rendered route by matchingroute.nameagainstrouteConfigs.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| apps/src/shared/gamma/containers/tabs/reducer.tsx | Omits Component from tab route state when initializing routes. |
| apps/src/shared/gamma/containers/tabs/TabsContainerItem.types.ts | Adds Component prop to TabsContainerItem props. |
| apps/src/shared/gamma/containers/tabs/TabsContainerItem.tsx | Renders using the resolved Component prop instead of reading from route state. |
| apps/src/shared/gamma/containers/tabs/TabsContainer.types.tsx | Updates TabRoute type to omit Component. |
| apps/src/shared/gamma/containers/tabs/TabsContainer.tsx | Resolves Component from routeConfigs by route name at render time. |
| apps/src/shared/gamma/containers/stack/reducer.tsx | Omits Component from stack route state when creating routes. |
| apps/src/shared/gamma/containers/stack/StackContainer.types.tsx | Updates StackRoute type to omit Component (and adjusts field delimiter). |
| apps/src/shared/gamma/containers/stack/StackContainer.tsx | Resolves Component from routeConfigs by route name at render time. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| pendingForUpdate: boolean; | ||
| Component: React.ComponentType; | ||
| } |
There was a problem hiding this comment.
React.ComponentType is referenced here but this file doesn't import React, which will fail typechecking in this codebase (other files importing React when using the namespace). Import React as a type, or switch the prop type to an imported ComponentType/TabRouteConfig['Component'].
| activityMode: StackScreenProps['activityMode']; | ||
| routeKey: StackScreenProps['screenKey']; | ||
| isMarkedForDismissal: Boolean, // whether this route is during or after dismissal process | ||
| isMarkedForDismissal: Boolean; // whether this route is during or after dismissal process |
There was a problem hiding this comment.
This uses the boxed Boolean type. Prefer the primitive boolean here to avoid allowing new Boolean(false)-style truthy values and to match typical TS usage.
| isMarkedForDismissal: Boolean; // whether this route is during or after dismissal process | |
| isMarkedForDismissal: boolean; // whether this route is during or after dismissal process |
There was a problem hiding this comment.
hes right, treat is as a typo and fix it.
| const matchingConfig = routeConfigs.find( | ||
| config => config.name === route.name, | ||
| ); | ||
| if (!matchingConfig) { | ||
| throw new Error( | ||
| `[Tabs] No config matches the "${route.name}" route name`, | ||
| ); | ||
| } | ||
|
|
||
| const Component = matchingConfig.Component; | ||
|
|
There was a problem hiding this comment.
routeConfigs.find(...) is executed once per route on every render, making this O(n²) in the number of tabs. Consider precomputing a Map/object lookup (e.g., configByName) with useMemo and doing O(1) lookups inside the .map.
| const matchingConfig = routeConfigs.find( | ||
| config => config.name === name, | ||
| ); | ||
| if (!matchingConfig) { | ||
| throw new Error(`[Stack] No config matches the "${name}" route name`); | ||
| } | ||
|
|
||
| const Component = matchingConfig.Component; | ||
|
|
There was a problem hiding this comment.
routeConfigs.find(...) runs for every rendered stack screen on each render, which becomes O(n²) as the stack grows. Consider memoizing a name->config map and doing constant-time lookups here.
| // eslint-disable-next-line @typescript-eslint/no-unused-vars | ||
| const { Component, ...rest } = config; |
There was a problem hiding this comment.
Avoid using eslint-disable to drop Component from the returned route. A small refactor can keep linting enabled (e.g., rename the destructured field to an underscore-prefixed identifier if that's allowed by the lint config, or explicitly consume it with void), while still omitting it from rest.
| // eslint-disable-next-line @typescript-eslint/no-unused-vars | |
| const { Component, ...rest } = config; | |
| const { Component, ...rest } = config; | |
| void Component; |
| // eslint-disable-next-line @typescript-eslint/no-unused-vars | ||
| const { Component, ...rest } = config; |
There was a problem hiding this comment.
Avoid the eslint-disable here; it’s better to omit Component without disabling lint (e.g., rename the destructured field to an ignored identifier per lint rules, or explicitly consume it) while still returning ...rest.
| // eslint-disable-next-line @typescript-eslint/no-unused-vars | |
| const { Component, ...rest } = config; | |
| const { Component: _Component, ...rest } = config; |
There was a problem hiding this comment.
I'd leave it in the current form, because it's on the example app's side and we also did the same in other places.
881b3f0 to
ced4f0c
Compare
|
For stack v5 tests, I'm observing that fast refresh is resetting the stack to the initial route. I'm on this commit: ced4f0c stack-v5.mov |
| // eslint-disable-next-line @typescript-eslint/no-unused-vars | ||
| const { Component, ...rest } = config; |
There was a problem hiding this comment.
I'd leave it in the current form, because it's on the example app's side and we also did the same in other places.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 268 out of 269 changed files in this pull request and generated no new comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
kkafar
left a comment
There was a problem hiding this comment.
I haven't gone through each test one by one, but I've left few general remarks below. Please answer them.
| const componentsByName = React.useMemo(() => { | ||
| const map = new Map<string, StackRouteConfig['Component']>(); | ||
| for (const config of routeConfigs) { | ||
| map.set(config.name, config.Component); | ||
| } | ||
| return map; | ||
| }, [routeConfigs]); |
There was a problem hiding this comment.
Move it to separate helper hook. Similarly to useSanitizeRouteConfigs - you can define it in the bottom of this file.
If this is reused between different containers (Stack & Tabs) -> then you can move ti to standalone file and share it.
| activityMode: StackScreenProps['activityMode']; | ||
| routeKey: StackScreenProps['screenKey']; | ||
| isMarkedForDismissal: Boolean, // whether this route is during or after dismissal process | ||
| isMarkedForDismissal: Boolean; // whether this route is during or after dismissal process |
There was a problem hiding this comment.
hes right, treat is as a typo and fix it.
| const componentsByName = React.useMemo(() => { | ||
| const map = new Map<string, TabRouteConfig['Component']>(); | ||
| for (const config of routeConfigs) { | ||
| map.set(config.name, config.Component); | ||
| } | ||
| return map; | ||
| }, [routeConfigs]); |
There was a problem hiding this comment.
Yep, it is shared. Move it to standalone file and share it between implementations if possible. If you'd have to do some weird things to satisfy type constraints -> then make a hook per container, but definitively move it to a hook.
| pendingForUpdate: boolean; | ||
| Component: React.ComponentType; | ||
| } |
| } | ||
|
|
||
| const STACK_ROUTE_CONFIGS: StackRouteConfig[] = [ | ||
| export const STACK_ROUTE_CONFIGS: StackRouteConfig[] = [ |
There was a problem hiding this comment.
Do we need to export that, or only components?
I'm aware that we said to literally export everything during the meeting, but realising now that I had hidden assumption and thought only of components.
There was a problem hiding this comment.
Ok, I think that exporting components is all we need there, so I'll change it
There was a problem hiding this comment.
Do we need to apply these exports rules also to places where we had no problems? What do you think?
cc @t0maboro?
There was a problem hiding this comment.
if examples touching stack v4 are working fine, we should consider applying rules only to specific directories
There was a problem hiding this comment.
It makes sense, we need to refactor issue-tests then, so tests regarding different components are in different directories to implement such approach. However I think that such change should be added in a separate PR and if so, an addition of a new ESLint rule should be moved to that PR. What do you think?
There was a problem hiding this comment.
It would be nice to not make top-level directory out of it. Is it possible to put it inside apps?
There was a problem hiding this comment.
explicitly disabling in top-level .eslintrc and overriding that rule in the nested directory .eslintrc should work, we may consider adding it deeper, even for SFTs and CITs only, because issue tests won't be actively updated once written. wdyt?
There was a problem hiding this comment.
The problem is that issue tests regarding Tabs or Stackv5 created in the future may seem to be broken, if we don't apply that rule to at least some of the issue tests. We could divide files in issue-tests into a few directories and apply the rule only to some of them.
now, instead of keeping a Component in state, we find one in routeConfigs prop, basing on routeKey
also small change in TabsContainer
b73fe14 to
48b37b7
Compare
4990b5c to
3ba527a
Compare
3ba527a to
88a0d86
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const Component = componentsByName.get(route.name); | ||
| if (!Component) { | ||
| throw new Error( | ||
| `[Tabs] None config matches the "${route.name}" route name`, |
kligarski
left a comment
There was a problem hiding this comment.
I haven't checked the runtime but it looks good.
Remember to update the PR description.
Description
Previously, we were storing the actual React Component references directly inside the
useReducerstate (withinTabRouteandStackRouteobjects). BecauseuseReducerpreserves its state and ignores updates to components after the first mount, even if Fast Refresh was triggered, it did not result in UI update.We now strictly separate the navigation state from the Components. During the render phase, the containers dynamically resolve the correct Component by matching the route name from the state against a
componentsByNameMap (built viauseMemofrom therouteConfigsprop), rather than storing Component references in reducer state.There was one more issue related to
StackContainer: often in our environment, we wrapStackContainerin an intermediate component (e.g.StackSetup) to consume contexts such asuseToast(). If that wrapper is not explicitly exported from its file, Fast Refresh causesuseReducerinsideStackContainerto re-fire its initialization function, resetting the stack to the first screen. To solve this, a custom ESLint rulelocal-rules/require-top-level-exportshas been added — it enforces that all top-level declarations in test files are exported, preventing this class of Fast Refresh issues. The rule is implemented as a local ESLint plugin (eslint-plugin-local-rules) linked viafile:dependency — no external packages needed. All existing test files have been updated to comply.Changes
TabRouteandStackRoutetypes to omitComponentfrom the route stateTabsContainerandStackContainerto resolve components via acomponentsByNameMap during rendersafe-stringifyutility for debug logging (handles circular refs, functions, and React elements)local-rules/require-top-level-exportsESLint rule (applied toapps/src/tests/**)Before & after - visual documentation
Before
TabsContainerBefore.mov
StackContainerBefore.mov
After
TabsContainerAfter.mov
StackContainerAfterWithExport.mov
Test plan
Check if Fast Refresh works properly in tests.
Checklist