Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโ€™ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[PM-3530] persist extension popup view #9556

Open
wants to merge 20 commits into
base: main
Choose a base branch
from

Conversation

willmartian
Copy link
Contributor

@willmartian willmartian commented Jun 9, 2024

๐ŸŽŸ๏ธ Tracking

https://bitwarden.atlassian.net/browse/PM-3530

๐Ÿ“” Objective

Adds a few utilities for persisting the extension popup view:

  • PopupRouterCacheService
    • saves the route history to state
    • redirects to the last saved route with popupRouterCacheGuard
    • gated behind FeatureFlag.PersistPopupView
  • PopupViewCacheService
    • cacheSignal, for caching arbitrary values
    • cacheFormGroup, for caching dirty form values
  • PopupViewCacheBackgroundService
    • clears cached state on unlock, lock, & 2min after closing the popup window
      • Couldn't think of a good way to capture this in Jest. Would appreciate some pointers here, otherwise I will just let QA capture the behavior in e2e tests.

misc changes:

  • The value passed to chip-select.component.ts & select.component.ts is not restricted to generics, and was previously being checked for equality with ===. This no longer works because the value that is grabbed from state will not be the same object reference: FolderView.fromJson({ ... }) !== FolderView.fromJson({ ... })

One way to fix this is to JSON.serialize both sides of the equality check, which I did here. However, devs may run into issues if they are checking for object equality elsewhere in their code.

Other options include:

  • only allow JSON-safe types in cacheSignal and cacheFormGroup
    • the requires more refactoring by end devs, because some forms hold non-primitive values
    • we could removing the deserializer option entirely
    • simplest path forward
  • encourage devs to retrieved existing object references in the deserializer:
    • may require some other factors if the deserializer needs to be async
{
  //...,
  deserializer: jsonValue => ({
    folderView: this.foldersService.getFolderById(jsonValue.folderView.id)
  })  
}

Other approaches

I initially wanted to utilize a simpler, non-reactive API like the following, which accepts a cleanup function that runs when the popup's pagehide event occurs:

class Foo {
  name: string = getFromCache("foo-name", () => {
    return this.name; 
  });
}

However, pagehide (and visibilitychange/unload) do not consistently fire in Chrome extension windows. :(

๐Ÿ“ธ Screenshots

Recording based on #9911 (I recommend reviewers take a look)

Screen.Recording.2024-07-02.at.9.58.27.AM.mov

โฐ Reminders before review

  • Contributor guidelines followed
  • All formatters and local linters executed and passed
  • Written new unit and / or integration tests where applicable
  • Protected functional changes with optionality (feature flags)
  • Used internationalization (i18n) for all UI strings
  • CI builds passed
  • Communicated to DevOps any deployment requirements
  • Updated any necessary documentation (Confluence, contributing docs) or informed the documentation team

๐Ÿฆฎ Reviewer guidelines

  • ๐Ÿ‘ (:+1:) or similar for great changes
  • ๐Ÿ“ (:memo:) or โ„น๏ธ (:information_source:) for notes or general info
  • โ“ (:question:) for questions
  • ๐Ÿค” (:thinking:) or ๐Ÿ’ญ (:thought_balloon:) for more open inquiry that's not quite a confirmed issue and could potentially benefit from discussion
  • ๐ŸŽจ (:art:) for suggestions / improvements
  • โŒ (:x:) or โš ๏ธ (:warning:) for more significant problems or concerns needing attention
  • ๐ŸŒฑ (:seedling:) or โ™ป๏ธ (:recycle:) for future improvements or indications of technical debt
  • โ› (:pick:) for minor or nitpick changes

@github-actions github-actions bot added the needs-qa Marks a PR as requiring QA approval label Jun 9, 2024
Copy link

codecov bot commented Jun 9, 2024

Codecov Report

Attention: Patch coverage is 61.11111% with 56 lines in your changes missing coverage. Please review.

Project coverage is 29.66%. Comparing base (150c4b6) to head (6a0d99a).
Report is 10 commits behind head on main.

Files Patch % Lines
...rm/services/popup-view-cache-background.service.ts 33.33% 22 Missing โš ๏ธ
...orm/popup/view-cache/popup-router-cache.service.ts 70.00% 14 Missing and 1 partial โš ๏ธ
apps/browser/src/popup/app.component.ts 0.00% 6 Missing โš ๏ธ
apps/browser/src/background/main.background.ts 0.00% 4 Missing โš ๏ธ
...tform/popup/view-cache/popup-view-cache.service.ts 90.24% 2 Missing and 2 partials โš ๏ธ
...rc/platform/popup/layout/popup-header.component.ts 60.00% 2 Missing โš ๏ธ
apps/browser/src/popup/app-routing.module.ts 0.00% 1 Missing โš ๏ธ
...omponents/src/chip-select/chip-select.component.ts 0.00% 1 Missing โš ๏ธ
libs/components/src/select/select.component.ts 0.00% 1 Missing โš ๏ธ
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #9556      +/-   ##
==========================================
+ Coverage   29.56%   29.66%   +0.10%     
==========================================
  Files        2538     2551      +13     
  Lines       74166    74626     +460     
  Branches    13855    13947      +92     
==========================================
+ Hits        21925    22138     +213     
- Misses      50582    50827     +245     
- Partials     1659     1661       +2     

โ˜” View full report in Codecov by Sentry.
๐Ÿ“ข Have feedback on the report? Share it here.

Copy link
Contributor

github-actions bot commented Jun 9, 2024

Logo
Checkmarx One โ€“ Scan Summary & Details โ€“ fc76daf5-fb9f-47c2-8bf9-9f8d7ec9378d

No New Or Fixed Issues Found

@willmartian willmartian added the hold do not merge, do not approve yet label Jun 9, 2024
@willmartian
Copy link
Contributor Author

willmartian commented Jun 9, 2024

Hold until 64e6bab is removed.

edit: done

@willmartian willmartian removed the hold do not merge, do not approve yet label Jul 2, 2024
@willmartian willmartian marked this pull request as ready for review July 2, 2024 14:20
@willmartian willmartian requested review from a team as code owners July 2, 2024 14:20
@vleague2
Copy link
Contributor

vleague2 commented Jul 3, 2024

I think I would prefer to accept only primitive types in the cache, such as id strings that can then be mapped in the code back to the objects they represent. This feels more like the way I'd expect to interact with a database -- store and receive only a reference key for an object, and then build mapping utilities to match that to the correct object in the code. Then we can sidestep the issues of object equality and remove the changes to the chip select and select. I think it would be more maintainable (i.e. we wouldn't have to keep making changes to other components or code that eventually become involved with the caches) and it's not an unreasonable amount of work for devs to map to and from the cache objects.

Copy link
Contributor

@audreyality audreyality left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

โŒ Seems like this could leak arbitrary secrets from a view into plaintext global state.

๐Ÿค” This feature seems like it would be better handled on a case-by-case basis in the service rather than as a globally-available feature. The ๐ŸŽจ item explains how I'd hoped to add this feature to sends.

๐ŸŽจ Adding a save buffer is fairly easy to support with BufferedState. I think spending energy adding missing features to it will produce better outcomes, since as a state provider it's intended to be wrapped by services that can be fully-conscious of the security and consistency requirements of the data. For example, the buffer includes clearon flags so that you can control when save state is lost if necessary. It also includes a dependency that toggles when its downstream state is overwritten.

๐Ÿ‘๐Ÿป I'd like to note that solving the problem is awesome, I'm all for that. I just know from experience that a global UI state cache makes doing the wrong thing really easy, and doing the right thing very complex.

๐Ÿ“ A great example of that is how @vleague2 is talking about primitive storage and building a mapping layer. rxjs already solves arbitrary mapping with map, and because that's just a function the mapping is fast. It also blends very nicely with reactive controls.

// example output
svc.saveBuffer$(editing.id).pipe(
  map(i => ({ ...i, foo: i.bar }),
).subscribe(myFormGroup.patch);

๐Ÿ’ญ If you really want a "global" store, you could surface it as a service that surfaces rx interfaces.

// `TemporaryStateService` 
//   * Wraps state providers with RxJs subjects
//   * Is backed by state providers that always clears on logout
const tss: TemporaryStateService = // construction or DI or whatever
tss.subject("myPageName"); // default protected by account key; fails if account locked
tss.subject("myPageName", { kind: "memory-only" }); // this kind never persists
tss.subject("myPageName", { kind: "plaintext" }); // this kind saves w/o encryption
tss.subject("myPageName", { clearon: ["leave"], }); // new clearon: user navigates away
tss.subject("myPageName", { kind: "device" }); // this kind is device key encrypted (if supported)

// rxjs subject supports subscription both ways
const tsub = tss.subject("myPageName");
tsub.value$.subscribe(myFormGroup.patchValue);
myFormGroup.valueChanges.subscribe(tsub.next);

//  similar could be done for signals, though that would draw a dependency on Angular's implementation atm
tss.signal(/* same args as subject */)

const updateUrl = !child?.data?.doNotSaveUrl ?? true;

if (updateUrl) {
void this.push(event.url);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to use void?

)) as UrlTree;

expect(serializer.serialize(response)).toBe("/b");
// expect(await service.last()).toBe("/a");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this a leftover that can be deleted?


expect(serializer.serialize(response)).toBe("/b");
});
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be worth adding a test for the service's back function since that gets used directly outside of the cache guard.

});

const value = _signal();
if (value != null && JSON.stringify(value) !== JSON.stringify(control.getRawValue())) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason to not use strict equality here? !==

}

/**
* Push new route onto history stack
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For these methods that return booleans, it might help to document what the return values signify.

testBed.inject(PopupRouterCacheService);
});

it("returns true if empty", async () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to add a little more context:

Suggested change
it("returns true if empty", async () => {
it("returns true if the history stack is empty", async () => {

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
needs-qa Marks a PR as requiring QA approval
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants