Merge "Remove the method which was added in jdk 16"
diff --git a/.mailmap b/.mailmap
new file mode 100644
index 0000000..721f3c0
--- /dev/null
+++ b/.mailmap
@@ -0,0 +1,97 @@
+Adrian Görler <adrian.goerler@sap.com>                                                      Adrian Goerler <adrian.goerler@sap.com>
+Ahaan Ugale <ahaanugale@gmail.com>                                                          <augale@codeaurora.org>
+Alex Blewitt <alex.blewitt@gmail.com>                                                       <alex.blewitt@gs.com>
+Alex Blewitt <alex.blewitt@gmail.com>                                                       <alex.blewitt@credit-suisse.com>
+Alex Ryazantsev <alex.ryazantsev@gmail.com>                                                 alex <alex.ryazantsev@gmail.com>
+Alex Ryazantsev <alex.ryazantsev@gmail.com>                                                 alex.ryazantsev <alex.ryazantsev@gmail.com>
+Alice Kober-Sotzek <aliceks@google.com>                                                     <aliceks@google.com>
+Alexandre Philbert <alexandre.philbert@ericsson.com>                                        <alexandre.philbert@hotmail.com>
+Andrew Bonventre <andybons@chromium.org>                                                    <andybons@google.com>
+Becky Siegel <beckysiegel@google.com>                                                       beckysiegel <beckysiegel@google.com>
+Ben Rohlfs <brohlfs@google.com>                                                             brohlfs <brohlfs@google.com>
+Brad Larson <bklarson@gmail.com>                                                            <brad.larson@garmin.com>
+Bruce Zu <bruce.zu.run10@gmail.com>                                                         <bruce.zu@sonyericsson.com>
+Bruce Zu <bruce.zu.run10@gmail.com>                                                         <bruce.zu@sonymobile.com>
+Carlos Eduardo Baldacin <carloseduardo.baldacin@sonyericsson.com>                           carloseduardo.baldacin <carloseduardo.baldacin@sonyericsson.com>
+Chad Horohoe <chorohoe@wikimedia.org>                                                       <chadh@wikimedia.org>
+Changcheng Xiao <xchangcheng@google.com>                                                    xchangcheng
+Cheng Ke <chengke.info@gmail.com>                                                           <chengke.info@gmail.com>
+Dariusz Luksza <dluksza@collab.net>                                                         <dariusz@luksza.org>
+Darrien Glasser <darrien@arista.com>                                                        darrien <darrien@arista.com>
+Dave Borowitz <dborowitz@google.com>                                                        <dborowitz@google.com>
+David Ostrovsky <david@ostrovsky.org>                                                       <d.ostrovsky@gmx.de>
+David Ostrovsky <david@ostrovsky.org>                                                       <david.ostrovsky@gmail.com>
+David Pursehouse <dpursehouse@collab.net>                                                   <david.pursehouse@sonymobile.com>
+Deniz Türkoglu <deniz@spotify.com>                                                          Deniz Türkoglu <deniz@spotify.com>
+Deniz Türkoglu <deniz@spotify.com>                                                          Deniz Turkoglu <deniz@spotify.com>
+Doug Kelly <dougk.ff7@gmail.com>                                                            <doug.kelly@garmin.com>
+Edwin Kempin <ekempin@google.com>                                                           Edwin Kempin <edwin.kempin@gmail.com>
+Edwin Kempin <ekempin@google.com>                                                           Edwin Kempin <edwin.kempin@sap.com>
+Edwin Kempin <ekempin@google.com>                                                           ekempin <ekempin@google.com>
+Eryk Szymanski <eryksz@gmail.com>                                                           <eryksz@google.com>
+Fredrik Luthander <fredrik.luthander@sonymobile.com>                                        <fredrik@gandaraj.com>
+Fredrik Luthander <fredrik.luthander@sonymobile.com>                                        <fredrik.luthander@sonyericsson.com>
+Gerrit Code Review <no-reply@gerritcodereview.com>                                          <noreply-gerritcodereview@google.com>
+Gustaf Lundh <gustaflh@axis.com>                                                            <gustaf.lundh@axis.com>
+Gustaf Lundh <gustaflh@axis.com>                                                            <gustaf.lundh@sonyericsson.com>
+Gustaf Lundh <gustaflh@axis.com>                                                            <gustaf.lundh@sonymobile.com>
+Han-Wen Nienhuys <hanwen@google.com>                                                        <hanwen@google.com>
+Hector Oswaldo Caballero <hector.caballero@ericsson.com>                                    <hector.caballero@ericsson.com>
+Hugo Arès <hugo.ares@ericsson.com>                                                          Hugo Ares <hugo.ares@ericsson.com>
+Hugo Arès <hugo.ares@ericsson.com>                                                          <hugares@gmail.com>
+Jacek Centkowski <jcentkowski@collab.net>                                                   <gemincia.programs@gmail.com>
+Jacek Centkowski <jcentkowski@collab.net>                                                   <geminica.programs@gmail.com>
+James E. Blair <jeblair@redhat.com>                                                         <jeblair@hp.com>
+Jason Huntley <jhuntley@houghtonassociates.com>                                             jhuntley <jhuntley@houghtonassociates.com>
+Jiří Engelthaler <EngyCZ@gmail.com>                                                         <engycz@gmail.com>
+Joe Onorato <onoratoj@gmail.com>                                                            <joeo@android.com>
+Joel Dodge <dodgejoel@gmail.com>                                                            dodgejoel <dodgejoel@gmail.com>
+Johan Björk <jbjoerk@gmail.com>                                                             Johan Bjork <phb@spotify.com>
+JT Olds <hello@jtolds.com>                                                                  <jtolds@gmail.com>
+Kasper Nilsson <kaspern@google.com>                                                         <kaspern@google.com>
+Lawrence Dubé <ldube@audiokinetic.com>                                                      <ldube@audiokinetic.com>
+Lei Sun <lei.sun01@sap.com>                                                                 LeiSun <lei.sun01@sap.com>
+Lincoln Oliveira Campos Do Nascimento <lincoln.oliveiracamposdonascimento@sonyericsson.com> lincoln <lincoln.oliveiracamposdonascimento@sonyericsson.com>
+Luca Milanesio <luca.milanesio@gmail.com>                                                   <luca@gitent-scm.com>
+Magnus Bäck <magnus.back@axis.com>                                                          <baeck@google.com>
+Magnus Bäck <magnus.back@axis.com>                                                          <magnus.back@sonyericsson.com>
+Marco Miller <marco.miller@ericsson.com>                                                    <marco.mmiller@gmail.com>
+Mark Derricutt <mark.derricutt@smxemail.com>                                                <mark@talios.com>
+Martin Fick <mfick@codeaurora.org>                                                          <mogulguy10@gmail.com>
+Martin Fick <mfick@codeaurora.org>                                                          <mogulguy@yahoo.com>
+Martin Wallgren <martinwa@axis.com>                                                         <martin.wallgren@axis.com>
+Matthias Sohn <matthias.sohn@sap.com>                                                       <matthias.sohn@gmail.com>
+Maxime Guerreiro <maximeg@google.com>                                                       <maximeg@google.com>
+Michael Zhou <moz@google.com>                                                               <zhoumotongxue008@gmail.com>
+Monty Taylor <mordred@inaugust.com>                                                         <monty.taylor@gmail.com>
+Mônica Dionísio <monica.dionisio@sonyericsson.com>                                          monica.dionisio <monica.dionisio@sonyericsson.com>
+Nasser Grainawi <nasser@grainawi.org>                                                       <nasser@codeaurora.org>
+Nasser Grainawi <nasser@grainawi.org>                                                       <nasserg@quicinc.com>
+Orgad Shaneh <orgads@gmail.com>                                                             <orgad.shaneh@audiocodes.com>
+Paladox <thomasmulhall410@yahoo.com>                                                        <thomasmulhall410@yahoo.com>
+Patrick Hiesel <hiesel@google.com>                                                          <hiesel@hiesel-macbookpro2.roam.corp.google.com>
+Peter Jönsson <peter.joensson@gmail.com>                                                    Peter Jönsson <peter.joensson@gmail.com>
+Rafael Rabelo Silva <rafael.rabelosilva@sonyericsson.com>                                   rafael.rabelosilva <rafael.rabelosilva@sonyericsson.com>
+Réda Housni Alaoui <reda.housnialaoui@gmail.com>                                            <alaoui.rda@gmail.com>
+Richard Möhn <richard.moehn@posteo.de>                                                      <richard.moehn@fu-berlin.de>
+Sam Saccone <samccone@google.com>                                                           <samccone@gmail.com>
+Sam Saccone <samccone@google.com>                                                           <samccone@google.com>
+Saša Živkov <sasa.zivkov@sap.com>                                                           Sasa Zivkov <sasa.zivkov@sap.com>
+Saša Živkov <sasa.zivkov@sap.com>                                                           Saša Živkov <zivkov@gmail.com>
+Saša Živkov <sasa.zivkov@sap.com>                                                           Sasa Zivkov <zivkov@gmail.com>
+Scott Dial <scott@scottdial.com>                                                            <geekmug@gmail.com>
+Shawn Pearce <sop@google.com>                                                               Shawn O. Pearce <sop@google.com>
+Sixin Li <sixin210@gmail.com>                                                               sixin li <sixin210@gmail.com>
+Sven Selberg <svense@axis.com>                                                              <sven.selberg@axis.com>
+Sven Selberg <svense@axis.com>                                                              <sven.selberg@sonymobile.com>
+Thomas Dräbing <thomas.draebing@sap.com>                                                    <thomas.draebing@sap.com>
+Tom Wang <twang10@gmail.com>                                                                Tom <twang10@gmail.com>
+Tomas Westling <thomas.westling@sonyericsson.com>                                           thomas.westling <thomas.westling@sonyericsson.com>
+Ulrik Sjölin <ulrik.sjolin@sonyericsson.com>                                                <ulrik.sjolin@gmail.com>
+Ulrik Sjölin <ulrik.sjolin@sonyericsson.com>                                                Ulrik Sjolin <ulrik.sjolin@gmail.com>
+Ulrik Sjölin <ulrik.sjolin@sonyericsson.com>                                                Ulrik Sjölin <ulrik.sjolin@sonyericsson.com>
+Ulrik Sjölin <ulrik.sjolin@sonyericsson.com>                                                Ulrik Sjolin <ulrik.sjolin@sonyericsson.com>
+Viktar Donich <viktard@google.com>                                                          viktard
+Yuxuan 'fishy' Wang <fishywang@google.com>                                                  Yuxuan Wang <fishywang@google.com>
+Zalán Blénessy <zalanb@axis.com>                                                            Zalan Blenessy <zalanb@axis.com>
+飞 李 <lifei@7v1.net>                                                                       lifei <lifei@7v1.net>
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index 4fa5e68..8c1b1ce 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -144,6 +144,40 @@
 This enables plugins to influence other plugins by customizing or extending the
 their behaviour.
 
+When a plugin wants to expose an API that *must* not be further overridden by
+other plugins, it could use the additional annotation `@DynamicItem.Final` which
+also gives the option to further limit the name of the plugin that is designated
+to bind the only implementation available.
+
+For example, a `plugin B` may declare an API as `@DynamicItem.Final` which is then
+bound in its `ApiModule`.
+
+```
+ @DynamicItem.Final(implementedByPlugin = "plugin-b-impl")
+ public interface PluginAPI {}
+
+ public class PluginApiModule extends AbstractModule {
+   @Override
+   protected void configure() {
+      DynamicItem.itemOf(binder(), PluginAPI.class);
+   }
+ }
+```
+
+The above definition of the `PluginApi` would be allowed to bound only by
+the `plugin-b-impl` which would associate its implementation class.
+
+```
+public class PluginImpl implements PluginAPI {}
+
+ public class PluginImplModule extends AbstractModule {
+   @Override
+   protected void configure() {
+      DynamicItem.bind(binder(), PluginAPI.class).to(PluginImpl.class);
+   }
+ }
+```
+
 *Gotchas and Limitations*:
 
 - A `plugin A` depending on a `plugin B` (declaring a `Gerrit-ApiModule`),
diff --git a/java/com/google/gerrit/acceptance/ExtensionRegistry.java b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
index d2051d5..a35e427 100644
--- a/java/com/google/gerrit/acceptance/ExtensionRegistry.java
+++ b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
@@ -45,7 +45,6 @@
 import com.google.gerrit.extensions.webui.ResolveConflictsWebLink;
 import com.google.gerrit.server.ExceptionHook;
 import com.google.gerrit.server.account.GroupBackend;
-import com.google.gerrit.server.change.ChangeETagComputation;
 import com.google.gerrit.server.change.FilterIncludedIn;
 import com.google.gerrit.server.change.ReviewerSuggestion;
 import com.google.gerrit.server.config.ProjectConfigEntry;
@@ -81,7 +80,6 @@
   private final DynamicSet<SubmitRule> submitRules;
   private final DynamicSet<SubmitRequirement> submitRequirements;
   private final DynamicSet<ChangeMessageModifier> changeMessageModifiers;
-  private final DynamicSet<ChangeETagComputation> changeETagComputations;
   private final DynamicSet<ActionVisitor> actionVisitors;
   private final DynamicMap<DownloadScheme> downloadSchemes;
   private final DynamicSet<RefOperationValidationListener> refOperationValidationListeners;
@@ -128,7 +126,6 @@
       DynamicSet<SubmitRule> submitRules,
       DynamicSet<SubmitRequirement> submitRequirements,
       DynamicSet<ChangeMessageModifier> changeMessageModifiers,
-      DynamicSet<ChangeETagComputation> changeETagComputations,
       DynamicSet<ActionVisitor> actionVisitors,
       DynamicMap<DownloadScheme> downloadSchemes,
       DynamicSet<RefOperationValidationListener> refOperationValidationListeners,
@@ -170,7 +167,6 @@
     this.submitRules = submitRules;
     this.submitRequirements = submitRequirements;
     this.changeMessageModifiers = changeMessageModifiers;
-    this.changeETagComputations = changeETagComputations;
     this.actionVisitors = actionVisitors;
     this.downloadSchemes = downloadSchemes;
     this.refOperationValidationListeners = refOperationValidationListeners;
@@ -286,11 +282,6 @@
     }
 
     @CanIgnoreReturnValue
-    public Registration add(ChangeETagComputation changeETagComputation) {
-      return add(changeETagComputations, changeETagComputation);
-    }
-
-    @CanIgnoreReturnValue
     public Registration add(ActionVisitor actionVisitor) {
       return add(actionVisitors, actionVisitor);
     }
diff --git a/java/com/google/gerrit/extensions/registration/DynamicItem.java b/java/com/google/gerrit/extensions/registration/DynamicItem.java
index 4464af7..4cc22f9 100644
--- a/java/com/google/gerrit/extensions/registration/DynamicItem.java
+++ b/java/com/google/gerrit/extensions/registration/DynamicItem.java
@@ -14,9 +14,12 @@
 
 package com.google.gerrit.extensions.registration;
 
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
 import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.inject.Binder;
+import com.google.inject.BindingAnnotation;
 import com.google.inject.Key;
 import com.google.inject.Provider;
 import com.google.inject.ProvisionException;
@@ -25,6 +28,9 @@
 import com.google.inject.binder.LinkedBindingBuilder;
 import com.google.inject.util.Providers;
 import com.google.inject.util.Types;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
 import java.util.concurrent.atomic.AtomicReference;
 
 /**
@@ -36,6 +42,15 @@
  * exception is thrown.
  */
 public class DynamicItem<T> {
+
+  /** Annotate a DynamicItem to be final and being bound at most once. */
+  @Target({ElementType.TYPE})
+  @Retention(RUNTIME)
+  @BindingAnnotation
+  public @interface Final {
+    String implementedByPlugin() default "";
+  }
+
   /**
    * Declare a singleton {@code DynamicItem<T>} with a binder.
    *
diff --git a/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicTypes.java b/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicTypes.java
index 5b528cb..982ff98 100644
--- a/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicTypes.java
+++ b/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicTypes.java
@@ -14,12 +14,14 @@
 
 package com.google.gerrit.extensions.registration;
 
+import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.inject.Binding;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import com.google.inject.Key;
+import com.google.inject.ProvisionException;
 import com.google.inject.TypeLiteral;
 import java.lang.reflect.ParameterizedType;
 import java.util.ArrayList;
@@ -97,6 +99,26 @@
         DynamicItem<Object> item = (DynamicItem<Object>) e.getValue();
 
         for (Binding<Object> b : bindings(src, type)) {
+          Class<? super Object> rawType = type.getRawType();
+          DynamicItem.Final annotation = rawType.getAnnotation(DynamicItem.Final.class);
+          if (annotation != null) {
+            Object existingBinding = item.get();
+            if (existingBinding != null) {
+              throw new ProvisionException(
+                  String.format(
+                      "Attempting to bind a @DynamicItem.Final %s twice: it was already bound to %s and tried to bind again to %s",
+                      rawType.getName(), existingBinding, b));
+            }
+
+            String implementedByPlugin = annotation.implementedByPlugin();
+            if (!Strings.isNullOrEmpty(implementedByPlugin)
+                && !implementedByPlugin.equals(pluginName)) {
+              throw new ProvisionException(
+                  String.format(
+                      "Attempting to bind a @DynamicItem.Final %s to unexpected plugin: it was supposed to be bound to %s plugin but tried bind to %s plugin",
+                      rawType.getName(), implementedByPlugin, pluginName));
+            }
+          }
           handles.add(item.set(b.getKey(), b.getProvider(), pluginName));
         }
       }
diff --git a/java/com/google/gerrit/extensions/restapi/ETagView.java b/java/com/google/gerrit/extensions/restapi/ETagView.java
deleted file mode 100644
index 9ac1706..0000000
--- a/java/com/google/gerrit/extensions/restapi/ETagView.java
+++ /dev/null
@@ -1,20 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.extensions.restapi;
-
-/** A view which may change, although the underlying resource did not change */
-public interface ETagView<R extends RestResource> extends RestReadView<R> {
-  String getETag(R rsrc);
-}
diff --git a/java/com/google/gerrit/extensions/restapi/RestResource.java b/java/com/google/gerrit/extensions/restapi/RestResource.java
index 3c8144a..30916bc 100644
--- a/java/com/google/gerrit/extensions/restapi/RestResource.java
+++ b/java/com/google/gerrit/extensions/restapi/RestResource.java
@@ -29,9 +29,4 @@
     /** Returns time for the Last-Modified header. HTTP truncates the header value to seconds. */
     Timestamp getLastModified();
   }
-
-  /** A resource with an ETag. */
-  public interface HasETag {
-    String getETag();
-  }
 }
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index 6951398..0844ca9 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -40,7 +40,6 @@
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
-import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.Iterables;
@@ -65,7 +64,6 @@
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.CacheControl;
 import com.google.gerrit.extensions.restapi.DefaultInput;
-import com.google.gerrit.extensions.restapi.ETagView;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.NeedsParams;
@@ -110,12 +108,10 @@
 import com.google.gerrit.server.change.ChangeFinder;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.group.GroupAuditService;
-import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.PerformanceLogContext;
 import com.google.gerrit.server.logging.PerformanceLogger;
 import com.google.gerrit.server.logging.RequestId;
 import com.google.gerrit.server.logging.TraceContext;
-import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -497,7 +493,7 @@
             checkRequiresCapability(viewData);
           }
 
-          if (notModified(req, traceContext, viewData, rsrc)) {
+          if (notModified(req, rsrc)) {
             logger.atFinest().log("REST call succeeded: %d", SC_NOT_MODIFIED);
             res.sendError(SC_NOT_MODIFIED);
             return;
@@ -607,7 +603,7 @@
 
             statusCode = response.statusCode();
             response.headers().forEach((k, v) -> res.setHeader(k, v));
-            configureCaching(req, res, traceContext, rsrc, viewData, response.caching());
+            configureCaching(req, res, rsrc, response.caching());
             res.setStatus(statusCode);
             logger.atFinest().log("REST call succeeded: %d", statusCode);
           }
@@ -800,47 +796,6 @@
     globals.webSession.get().resetRefUpdatedEvents();
   }
 
-  private String getEtagWithRetry(
-      HttpServletRequest req,
-      TraceContext traceContext,
-      ViewData viewData,
-      ETagView<RestResource> view,
-      RestResource rsrc) {
-    try (TraceTimer ignored =
-        TraceContext.newTimer(
-            "RestApiServlet#getEtagWithRetry:view",
-            Metadata.builder().restViewName(getViewName(viewData)).build())) {
-      return invokeRestEndpointWithRetry(
-          req,
-          traceContext,
-          getViewName(viewData) + "#etag",
-          ActionType.REST_READ_REQUEST,
-          () -> view.getETag(rsrc));
-    } catch (Exception e) {
-      Throwables.throwIfUnchecked(e);
-      throw new IllegalStateException("Failed to get ETag for view", e);
-    }
-  }
-
-  @Nullable
-  private String getEtagWithRetry(
-      HttpServletRequest req, TraceContext traceContext, RestResource.HasETag rsrc) {
-    try (TraceTimer ignored =
-        TraceContext.newTimer(
-            "RestApiServlet#getEtagWithRetry:resource",
-            Metadata.builder().restViewName(rsrc.getClass().getSimpleName()).build())) {
-      return invokeRestEndpointWithRetry(
-          req,
-          traceContext,
-          rsrc.getClass().getSimpleName() + "#etag",
-          ActionType.REST_READ_REQUEST,
-          () -> rsrc.getETag());
-    } catch (Exception e) {
-      Throwables.throwIfUnchecked(e);
-      throw new IllegalStateException("Failed to get ETag for resource", e);
-    }
-  }
-
   private RestResource parseResourceWithRetry(
       HttpServletRequest req,
       TraceContext traceContext,
@@ -1012,30 +967,11 @@
     return defaultMessage;
   }
 
-  private boolean notModified(
-      HttpServletRequest req, TraceContext traceContext, ViewData viewData, RestResource rsrc) {
+  private boolean notModified(HttpServletRequest req, RestResource rsrc) {
     if (!isRead(req)) {
       return false;
     }
 
-    RestView<RestResource> view = viewData.view;
-    if (view instanceof ETagView) {
-      String have = req.getHeader(HttpHeaders.IF_NONE_MATCH);
-      if (have != null) {
-        String eTag =
-            getEtagWithRetry(req, traceContext, viewData, (ETagView<RestResource>) view, rsrc);
-        return have.equals(eTag);
-      }
-    }
-
-    if (rsrc instanceof RestResource.HasETag) {
-      String have = req.getHeader(HttpHeaders.IF_NONE_MATCH);
-      if (!Strings.isNullOrEmpty(have)) {
-        String eTag = getEtagWithRetry(req, traceContext, (RestResource.HasETag) rsrc);
-        return have.equals(eTag);
-      }
-    }
-
     if (rsrc instanceof RestResource.HasLastModified) {
       Timestamp m = ((RestResource.HasLastModified) rsrc).getLastModified();
       long d = req.getDateHeader(HttpHeaders.IF_MODIFIED_SINCE);
@@ -1047,12 +983,7 @@
   }
 
   private <R extends RestResource> void configureCaching(
-      HttpServletRequest req,
-      HttpServletResponse res,
-      TraceContext traceContext,
-      R rsrc,
-      ViewData viewData,
-      CacheControl cacheControl) {
+      HttpServletRequest req, HttpServletResponse res, R rsrc, CacheControl cacheControl) {
     setCacheHeaders(req, res, cacheControl);
     if (isRead(req)) {
       switch (cacheControl.getType()) {
@@ -1060,10 +991,10 @@
         default:
           break;
         case PRIVATE:
-          addResourceStateHeaders(req, res, traceContext, viewData, rsrc);
+          addResourceStateHeaders(res, rsrc);
           break;
         case PUBLIC:
-          addResourceStateHeaders(req, res, traceContext, viewData, rsrc);
+          addResourceStateHeaders(res, rsrc);
           break;
       }
     }
@@ -1095,23 +1026,7 @@
     }
   }
 
-  private void addResourceStateHeaders(
-      HttpServletRequest req,
-      HttpServletResponse res,
-      TraceContext traceContext,
-      ViewData viewData,
-      RestResource rsrc) {
-    RestView<RestResource> view = viewData.view;
-    if (view instanceof ETagView) {
-      String eTag =
-          getEtagWithRetry(req, traceContext, viewData, (ETagView<RestResource>) view, rsrc);
-      res.setHeader(HttpHeaders.ETAG, eTag);
-    } else if (rsrc instanceof RestResource.HasETag) {
-      String eTag = getEtagWithRetry(req, traceContext, (RestResource.HasETag) rsrc);
-      if (!Strings.isNullOrEmpty(eTag)) {
-        res.setHeader(HttpHeaders.ETAG, eTag);
-      }
-    }
+  private void addResourceStateHeaders(HttpServletResponse res, RestResource rsrc) {
     if (rsrc instanceof RestResource.HasLastModified) {
       res.setDateHeader(
           HttpHeaders.LAST_MODIFIED,
diff --git a/java/com/google/gerrit/server/change/ChangeETagComputation.java b/java/com/google/gerrit/server/change/ChangeETagComputation.java
deleted file mode 100644
index 2fd5755..0000000
--- a/java/com/google/gerrit/server/change/ChangeETagComputation.java
+++ /dev/null
@@ -1,63 +0,0 @@
-// Copyright (C) 2019 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.extensions.annotations.ExtensionPoint;
-
-/**
- * Allows plugins to contribute a value to the change ETag computation.
- *
- * <p>Plugins can affect the result of the get change / get change details REST endpoints by:
- *
- * <ul>
- *   <li>providing plugin defined attributes to {@link
- *       com.google.gerrit.extensions.common.ChangeInfo#plugins} (see {@link
- *       ChangePluginDefinedInfoFactory})
- *   <li>implementing a {@link com.google.gerrit.server.rules.SubmitRule} which affects the
- *       computation of {@link com.google.gerrit.extensions.common.ChangeInfo#submittable}
- * </ul>
- *
- * <p>If the plugin defined part of {@link com.google.gerrit.extensions.common.ChangeInfo} depends
- * on plugin specific data, callers that use the change ETags to avoid unneeded recomputations of
- * ChangeInfos may see outdated plugin attributes and/or outdated submittable information, because a
- * ChangeInfo is only reloaded if the change ETag changes.
- *
- * <p>By implementating this interface plugins can contribute to the change ETag computation and
- * thus ensure that the ETag changes when the plugin data was changed. This way it is ensured that
- * callers do not see outdated ChangeInfos.
- *
- * @see ChangeResource#getETag()
- */
-@ExtensionPoint
-public interface ChangeETagComputation {
-  /**
-   * Computes an ETag of plugin-specific data for the given change.
-   *
-   * <p><strong>Note:</strong> Change ETags are computed very frequently and the computation must be
-   * cheap. Take good care to not perform any expensive computations when implementing this.
-   *
-   * <p>If an error is encountered during the ETag computation the plugin can indicate this by
-   * throwing any RuntimeException. In this case no value will be included in the change ETag
-   * computation. This means if the error is transient, the ETag will differ when the computation
-   * succeeds on a follow-up run.
-   *
-   * @param projectName the name of the project that contains the change
-   * @param changeId ID of the change for which the ETag should be computed
-   * @return the ETag
-   */
-  String getETag(Project.NameKey projectName, Change.Id changeId);
-}
diff --git a/java/com/google/gerrit/server/change/ChangeResource.java b/java/com/google/gerrit/server/change/ChangeResource.java
index 99a0b50..a7fa6f4 100644
--- a/java/com/google/gerrit/server/change/ChangeResource.java
+++ b/java/com/google/gerrit/server/change/ChangeResource.java
@@ -14,56 +14,20 @@
 
 package com.google.gerrit.server.change;
 
-import static com.google.gerrit.server.experiments.ExperimentFeaturesConstants.DISABLE_CHANGE_ETAGS;
-import static com.google.gerrit.server.project.ProjectCache.illegalState;
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.common.base.MoreObjects;
-import com.google.common.hash.Hasher;
-import com.google.common.hash.Hashing;
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.restapi.RestResource;
-import com.google.gerrit.extensions.restapi.RestResource.HasETag;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.StarredChangesReader;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.approval.ApprovalsUtil;
-import com.google.gerrit.server.experiments.ExperimentFeatures;
-import com.google.gerrit.server.logging.Metadata;
-import com.google.gerrit.server.logging.TraceContext;
-import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.plugincontext.PluginSetContext;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.TypeLiteral;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
-import java.util.HashSet;
-import java.util.Optional;
-import java.util.Set;
-import org.eclipse.jgit.lib.ObjectId;
 
-public class ChangeResource implements RestResource, HasETag {
-  /**
-   * JSON format version number for ETag computations.
-   *
-   * <p>Should be bumped on any JSON format change (new fields, etc.) so that otherwise unmodified
-   * changes get new ETags.
-   */
-  public static final int JSON_FORMAT_VERSION = 1;
-
+public class ChangeResource implements RestResource {
   public static final TypeLiteral<RestView<ChangeResource>> CHANGE_KIND = new TypeLiteral<>() {};
 
   public interface Factory {
@@ -72,64 +36,27 @@
     ChangeResource create(ChangeData changeData, CurrentUser user);
   }
 
-  private static final String ZERO_ID_STRING = ObjectId.zeroId().name();
-
-  private final ExperimentFeatures experimentFeatures;
-  private final AccountCache accountCache;
-  private final ApprovalsUtil approvalUtil;
-  private final PatchSetUtil patchSetUtil;
   private final PermissionBackend permissionBackend;
-  private final StarredChangesReader starredChangesReader;
-  private final ProjectCache projectCache;
-  private final PluginSetContext<ChangeETagComputation> changeETagComputation;
   private final ChangeData changeData;
   private final CurrentUser user;
 
   @AssistedInject
   ChangeResource(
-      ExperimentFeatures experimentFeatures,
-      AccountCache accountCache,
-      ApprovalsUtil approvalUtil,
-      PatchSetUtil patchSetUtil,
       PermissionBackend permissionBackend,
-      StarredChangesReader starredChangesReader,
-      ProjectCache projectCache,
-      PluginSetContext<ChangeETagComputation> changeETagComputation,
       ChangeData.Factory changeDataFactory,
       @Assisted ChangeNotes notes,
       @Assisted CurrentUser user) {
-    this.experimentFeatures = experimentFeatures;
-    this.accountCache = accountCache;
-    this.approvalUtil = approvalUtil;
-    this.patchSetUtil = patchSetUtil;
     this.permissionBackend = permissionBackend;
-    this.starredChangesReader = starredChangesReader;
-    this.projectCache = projectCache;
-    this.changeETagComputation = changeETagComputation;
     this.changeData = changeDataFactory.create(notes);
     this.user = user;
   }
 
   @AssistedInject
   ChangeResource(
-      ExperimentFeatures experimentFeatures,
-      AccountCache accountCache,
-      ApprovalsUtil approvalUtil,
-      PatchSetUtil patchSetUtil,
       PermissionBackend permissionBackend,
-      StarredChangesReader starredChangesReader,
-      ProjectCache projectCache,
-      PluginSetContext<ChangeETagComputation> changeETagComputation,
       @Assisted ChangeData changeData,
       @Assisted CurrentUser user) {
-    this.experimentFeatures = experimentFeatures;
-    this.accountCache = accountCache;
-    this.approvalUtil = approvalUtil;
-    this.patchSetUtil = patchSetUtil;
     this.permissionBackend = permissionBackend;
-    this.starredChangesReader = starredChangesReader;
-    this.projectCache = projectCache;
-    this.changeETagComputation = changeETagComputation;
     this.changeData = changeData;
     this.user = user;
   }
@@ -171,111 +98,4 @@
   public Change.Id getVirtualId() {
     return getChangeData().virtualId();
   }
-
-  // This includes all information relevant for ETag computation
-  // unrelated to the UI.
-  public void prepareETag(Hasher h, CurrentUser user) {
-    h.putInt(JSON_FORMAT_VERSION)
-        .putLong(getChange().getLastUpdatedOn().toEpochMilli())
-        .putInt(user.isIdentifiedUser() ? user.getAccountId().get() : 0);
-
-    if (user.isIdentifiedUser()) {
-      for (AccountGroup.UUID uuid : user.getEffectiveGroups().getKnownGroups()) {
-        h.putBytes(uuid.get().getBytes(UTF_8));
-      }
-    }
-
-    byte[] buf = new byte[20];
-    Set<Account.Id> accounts = new HashSet<>();
-    accounts.add(getChange().getOwner());
-    try {
-      patchSetUtil.byChange(getNotes()).stream().map(PatchSet::uploader).forEach(accounts::add);
-
-      // It's intentional to include the states for *all* reviewers into the ETag computation.
-      // We need the states of all current reviewers and CCs because they are part of ChangeInfo.
-      // Including removed reviewers is a cheap way of making sure that the states of accounts that
-      // posted a message on the change are included. Loading all change messages to find the exact
-      // set of accounts that posted a message is too expensive. However everyone who posts a
-      // message is automatically added as reviewer. Hence if we include removed reviewers we can
-      // be sure that we have all accounts that posted messages on the change.
-      accounts.addAll(approvalUtil.getReviewers(getNotes()).all());
-    } catch (StorageException e) {
-      // This ETag will be invalidated if it loads next time.
-    }
-
-    for (Account.Id accountId : accounts) {
-      Optional<AccountState> accountState = accountCache.get(accountId);
-      if (accountState.isPresent()) {
-        hashAccount(h, accountState.get(), buf);
-      } else {
-        h.putInt(accountId.get());
-      }
-    }
-
-    ObjectId noteId;
-    try {
-      noteId = getNotes().loadRevision();
-    } catch (StorageException e) {
-      noteId = null; // This ETag will be invalidated if it loads next time.
-    }
-    hashObjectId(h, noteId, buf);
-    // TODO(dborowitz): Include more NoteDb and other related refs, e.g. drafts
-    // and edits.
-
-    Iterable<ProjectState> projectStateTree =
-        projectCache.get(getProject()).orElseThrow(illegalState(getProject())).tree();
-    for (ProjectState p : projectStateTree) {
-      hashObjectId(h, p.getConfig().getRevision().orElse(null), buf);
-    }
-
-    changeETagComputation.runEach(
-        c -> {
-          String pluginETag = c.getETag(changeData.project(), changeData.getId());
-          if (pluginETag != null) {
-            h.putString(pluginETag, UTF_8);
-          }
-        });
-  }
-
-  @Override
-  @Nullable
-  public String getETag() {
-    if (experimentFeatures.isFeatureEnabled(DISABLE_CHANGE_ETAGS)) {
-      return null;
-    }
-
-    try (TraceTimer ignored =
-        TraceContext.newTimer(
-            "Compute change ETag",
-            Metadata.builder()
-                .changeId(changeData.getId().get())
-                .projectName(changeData.project().get())
-                .build())) {
-      Hasher h = Hashing.murmur3_128().newHasher();
-      if (user.isIdentifiedUser()) {
-        h.putBoolean(starredChangesReader.isStarred(user.getAccountId(), getVirtualId()));
-      }
-      prepareETag(h, user);
-      return h.hash().toString();
-    }
-  }
-
-  private void hashObjectId(Hasher h, @Nullable ObjectId id, byte[] buf) {
-    MoreObjects.firstNonNull(id, ObjectId.zeroId()).copyRawTo(buf, 0);
-    h.putBytes(buf);
-  }
-
-  private void hashAccount(Hasher h, AccountState accountState, byte[] buf) {
-    h.putInt(accountState.account().id().get());
-    // Based on the code, it seems the metaId should never be null in this place and so the
-    // uniqueTag.
-    // However, the null-check for metaId has been existed here for some time already - for safety
-    // the same check is applied to uniqueTag.
-    h.putString(
-        MoreObjects.firstNonNull(
-            accountState.account().uniqueTag(),
-            MoreObjects.firstNonNull(accountState.account().metaId(), ZERO_ID_STRING)),
-        UTF_8);
-    accountState.externalIds().stream().forEach(e -> hashObjectId(h, e.blobId(), buf));
-  }
 }
diff --git a/java/com/google/gerrit/server/change/RevisionResource.java b/java/com/google/gerrit/server/change/RevisionResource.java
index 4a10158..a09cb1f 100644
--- a/java/com/google/gerrit/server/change/RevisionResource.java
+++ b/java/com/google/gerrit/server/change/RevisionResource.java
@@ -14,26 +14,20 @@
 
 package com.google.gerrit.server.change;
 
-import com.google.common.hash.Hasher;
-import com.google.common.hash.Hashing;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.restapi.RestResource;
-import com.google.gerrit.extensions.restapi.RestResource.HasETag;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.edit.ChangeEdit;
-import com.google.gerrit.server.logging.Metadata;
-import com.google.gerrit.server.logging.TraceContext;
-import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.inject.TypeLiteral;
 import java.util.Optional;
 
-public class RevisionResource implements RestResource, HasETag {
+public class RevisionResource implements RestResource {
   public static final TypeLiteral<RestView<RevisionResource>> REVISION_KIND =
       new TypeLiteral<>() {};
 
@@ -90,28 +84,6 @@
     return ps;
   }
 
-  @Override
-  public String getETag() {
-    try (TraceTimer ignored =
-        TraceContext.newTimer(
-            "Compute revision ETag",
-            Metadata.builder()
-                .changeId(changeResource.getId().get())
-                .patchSetId(ps.number())
-                .projectName(changeResource.getProject().get())
-                .build())) {
-      Hasher h = Hashing.murmur3_128().newHasher();
-      prepareETag(h, getUser());
-      return h.hash().toString();
-    }
-  }
-
-  public void prepareETag(Hasher h, CurrentUser user) {
-    // Conservative estimate: refresh the revision if its parent change has changed, so we don't
-    // have to check whether a given modification affected this revision specifically.
-    changeResource.prepareETag(h, user);
-  }
-
   public Account.Id getAccountId() {
     return getUser().getAccountId();
   }
diff --git a/java/com/google/gerrit/server/change/testing/TestChangeETagComputation.java b/java/com/google/gerrit/server/change/testing/TestChangeETagComputation.java
deleted file mode 100644
index 344b9b3..0000000
--- a/java/com/google/gerrit/server/change/testing/TestChangeETagComputation.java
+++ /dev/null
@@ -1,30 +0,0 @@
-// Copyright (C) 2019 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change.testing;
-
-import com.google.gerrit.server.change.ChangeETagComputation;
-
-public class TestChangeETagComputation {
-
-  public static ChangeETagComputation withETag(String etag) {
-    return (p, id) -> etag;
-  }
-
-  public static ChangeETagComputation withException(RuntimeException e) {
-    return (p, id) -> {
-      throw e;
-    };
-  }
-}
diff --git a/java/com/google/gerrit/server/change/testing/package-info.java b/java/com/google/gerrit/server/change/testing/package-info.java
deleted file mode 100644
index 3cd4da3..0000000
--- a/java/com/google/gerrit/server/change/testing/package-info.java
+++ /dev/null
@@ -1,18 +0,0 @@
-// Copyright (C) 2023 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-@CheckReturnValue
-package com.google.gerrit.server.change.testing;
-
-import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index a38a3fc..2b26956 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -114,7 +114,6 @@
 import com.google.gerrit.server.cache.CacheRemovalListener;
 import com.google.gerrit.server.change.AbandonOp;
 import com.google.gerrit.server.change.AccountPatchReviewStore;
-import com.google.gerrit.server.change.ChangeETagComputation;
 import com.google.gerrit.server.change.ChangeFinder;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeKindCacheImpl;
@@ -458,7 +457,6 @@
     DynamicSet.setOf(binder(), PerformanceLogger.class);
     DynamicSet.setOf(binder(), RequestListener.class);
     DynamicSet.bind(binder(), RequestListener.class).to(TraceRequestListener.class);
-    DynamicSet.setOf(binder(), ChangeETagComputation.class);
     DynamicSet.setOf(binder(), ExceptionHook.class);
     DynamicSet.bind(binder(), ExceptionHook.class).to(ExceptionHookImpl.class);
     DynamicSet.setOf(binder(), MailSoyTemplateProvider.class);
diff --git a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
index 49b5d2a6..cd91745 100644
--- a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
+++ b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
@@ -60,7 +60,4 @@
   /** Whether we allow fix suggestions in HumanComments. */
   public static final String ALLOW_FIX_SUGGESTIONS_IN_COMMENTS =
       "GerritBackendFeature__allow_fix_suggestions_in_comments";
-
-  /** Whether etags should be disabled for change resources. */
-  public static final String DISABLE_CHANGE_ETAGS = "GerritBackendFeature__disable_change_etags";
 }
diff --git a/java/com/google/gerrit/server/restapi/change/Files.java b/java/com/google/gerrit/server/restapi/change/Files.java
index 96e5645..07e4372 100644
--- a/java/com/google/gerrit/server/restapi/change/Files.java
+++ b/java/com/google/gerrit/server/restapi/change/Files.java
@@ -17,8 +17,6 @@
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.common.flogger.FluentLogger;
-import com.google.common.hash.Hasher;
-import com.google.common.hash.Hashing;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
@@ -31,10 +29,10 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.CacheControl;
 import com.google.gerrit.extensions.restapi.ChildCollection;
-import com.google.gerrit.extensions.restapi.ETagView;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.PatchSetUtil;
@@ -47,7 +45,6 @@
 import com.google.gerrit.server.patch.DiffNotAvailableException;
 import com.google.gerrit.server.patch.DiffOperations;
 import com.google.gerrit.server.patch.DiffOptions;
-import com.google.gerrit.server.patch.PatchListKey;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -100,7 +97,7 @@
     return new FileResource(rev, id.get());
   }
 
-  public static final class ListFiles implements ETagView<RevisionResource> {
+  public static final class ListFiles implements RestReadView<RevisionResource> {
     private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
     @Option(name = "--base", metaVar = "revision-id")
@@ -353,16 +350,6 @@
       return this;
     }
 
-    @Override
-    public String getETag(RevisionResource resource) {
-      Hasher h = Hashing.murmur3_128().newHasher();
-      resource.prepareETag(h, resource.getUser());
-      // File list comes from the PatchListCache, so any change to the key or value should
-      // invalidate ETag.
-      h.putLong(PatchListKey.serialVersionUID);
-      return h.hash().toString();
-    }
-
     @Nullable
     private ObjectId getOldId(Map<String, FileDiffOutput> fileDiffList) {
       return fileDiffList.isEmpty()
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 6e635c9..f92702e 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -111,7 +111,6 @@
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.api.accounts.DeleteDraftCommentsInput;
 import com.google.gerrit.extensions.api.changes.AttentionSetInput;
@@ -170,7 +169,6 @@
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeMessages;
 import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.change.testing.TestChangeETagComputation;
 import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.git.ChangeMessageModifier;
 import com.google.gerrit.server.git.validators.CommitValidationException;
@@ -1543,7 +1541,6 @@
   private void testAddReviewerViaPostReview(AddReviewerCaller addReviewer) throws Exception {
     PushOneCommit.Result r = createChange();
     ChangeResource rsrc = parseResource(r);
-    String oldETag = rsrc.getETag();
     Instant oldTs = rsrc.getChange().getLastUpdatedOn();
 
     addReviewer.call(r.getChangeId(), user.email());
@@ -1567,16 +1564,9 @@
     // Nobody was added as CC.
     assertThat(c.reviewers.get(CC)).isNull();
 
-    // Ensure ETag and lastUpdatedOn are updated.
+    // Ensure lastUpdatedOn is updated.
     rsrc = parseResource(r);
-    assertThat(rsrc.getETag()).isNotEqualTo(oldETag);
     assertThat(rsrc.getChange().getLastUpdatedOn()).isNotEqualTo(oldTs);
-
-    // Change status of reviewer and ensure ETag is updated.
-    oldETag = rsrc.getETag();
-    accountOperations.account(user.id()).forUpdate().status("new status").update();
-    rsrc = parseResource(r);
-    assertThat(rsrc.getETag()).isNotEqualTo(oldETag);
   }
 
   @Test
@@ -1659,7 +1649,6 @@
   public void addReviewerThatIsNotPerfectMatch() throws Exception {
     PushOneCommit.Result r = createChange();
     ChangeResource rsrc = parseResource(r);
-    String oldETag = rsrc.getETag();
     Instant oldTs = rsrc.getChange().getLastUpdatedOn();
 
     // create a group named "ab" with one user: testUser
@@ -1697,9 +1686,8 @@
     assertThat(reviewers).hasSize(1);
     assertThat(reviewers.iterator().next()._accountId).isEqualTo(accountIdOfTestUser.get());
 
-    // Ensure ETag and lastUpdatedOn are updated.
+    // Ensure lastUpdatedOn is updated.
     rsrc = parseResource(r);
-    assertThat(rsrc.getETag()).isNotEqualTo(oldETag);
     assertThat(rsrc.getChange().getLastUpdatedOn()).isNotEqualTo(oldTs);
   }
 
@@ -1708,7 +1696,6 @@
   public void addGroupAsReviewersWhenANotPerfectMatchedUserExists() throws Exception {
     PushOneCommit.Result r = createChange();
     ChangeResource rsrc = parseResource(r);
-    String oldETag = rsrc.getETag();
     Instant oldTs = rsrc.getChange().getLastUpdatedOn();
 
     // create a group named "kobe" with one user: lee
@@ -1758,9 +1745,8 @@
     assertThat(reviewers).hasSize(1);
     assertThat(reviewers.iterator().next()._accountId).isEqualTo(accountIdOfGroupUser.get());
 
-    // Ensure ETag and lastUpdatedOn are updated.
+    // Ensure lastUpdatedOn is updated.
     rsrc = parseResource(r);
-    assertThat(rsrc.getETag()).isNotEqualTo(oldETag);
     assertThat(rsrc.getChange().getLastUpdatedOn()).isNotEqualTo(oldTs);
   }
 
@@ -1809,7 +1795,6 @@
   public void addSelfAsReviewer() throws Exception {
     PushOneCommit.Result r = createChange();
     ChangeResource rsrc = parseResource(r);
-    String oldETag = rsrc.getETag();
     Instant oldTs = rsrc.getChange().getLastUpdatedOn();
 
     ReviewerInput in = new ReviewerInput();
@@ -1827,9 +1812,8 @@
     assertThat(reviewers).hasSize(1);
     assertThat(reviewers.iterator().next()._accountId).isEqualTo(user.id().get());
 
-    // Ensure ETag and lastUpdatedOn are updated.
+    // Ensure lastUpdatedOn is updated.
     rsrc = parseResource(r);
-    assertThat(rsrc.getETag()).isNotEqualTo(oldETag);
     assertThat(rsrc.getChange().getLastUpdatedOn()).isNotEqualTo(oldTs);
   }
 
@@ -1920,56 +1904,6 @@
   }
 
   @Test
-  public void eTagChangesWhenOwnerUpdatesAccountStatus() throws Exception {
-    PushOneCommit.Result r = createChange();
-    ChangeResource rsrc = parseResource(r);
-    String oldETag = rsrc.getETag();
-
-    accountOperations.account(admin.id()).forUpdate().status("new status").update();
-    rsrc = parseResource(r);
-    assertThat(rsrc.getETag()).isNotEqualTo(oldETag);
-  }
-
-  @Test
-  public void pluginCanContributeToETagComputation() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String oldETag = parseResource(r).getETag();
-
-    try (Registration registration =
-        extensionRegistry.newRegistration().add(TestChangeETagComputation.withETag("foo"))) {
-      assertThat(parseResource(r).getETag()).isNotEqualTo(oldETag);
-    }
-
-    assertThat(parseResource(r).getETag()).isEqualTo(oldETag);
-  }
-
-  @Test
-  public void returningNullFromETagComputationDoesNotBreakGerrit() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String oldETag = parseResource(r).getETag();
-
-    try (Registration registration =
-        extensionRegistry.newRegistration().add(TestChangeETagComputation.withETag(null))) {
-      assertThat(parseResource(r).getETag()).isEqualTo(oldETag);
-    }
-  }
-
-  @Test
-  public void throwingExceptionFromETagComputationDoesNotBreakGerrit() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String oldETag = parseResource(r).getETag();
-
-    try (Registration registration =
-        extensionRegistry
-            .newRegistration()
-            .add(
-                TestChangeETagComputation.withException(
-                    new StorageException("exception during test")))) {
-      assertThat(parseResource(r).getETag()).isEqualTo(oldETag);
-    }
-  }
-
-  @Test
   public void emailNotificationForFileLevelComment() throws Exception {
     String changeId = createChange().getChangeId();
 
diff --git a/javatests/com/google/gerrit/acceptance/api/config/ListExperimentsIT.java b/javatests/com/google/gerrit/acceptance/api/config/ListExperimentsIT.java
index cef9ddd..fe3cb00 100644
--- a/javatests/com/google/gerrit/acceptance/api/config/ListExperimentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/config/ListExperimentsIT.java
@@ -52,7 +52,6 @@
                 .GERRIT_BACKEND_FEATURE_ALWAYS_REJECT_IMPLICIT_MERGES_ON_MERGE,
             ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_ATTACH_NONCE_TO_DOCUMENTATION,
             ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_CHECK_IMPLICIT_MERGES_ON_MERGE,
-            ExperimentFeaturesConstants.DISABLE_CHANGE_ETAGS,
             ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_REJECT_IMPLICIT_MERGES_ON_MERGE)
         .inOrder();
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeEtagIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeEtagIT.java
deleted file mode 100644
index 6df8c86..0000000
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeEtagIT.java
+++ /dev/null
@@ -1,56 +0,0 @@
-// Copyright (C) 2024 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.rest.change;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.common.net.HttpHeaders;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.RestResponse;
-import com.google.gerrit.acceptance.config.GerritConfig;
-import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
-import com.google.inject.Inject;
-import org.junit.Test;
-
-public class ChangeEtagIT extends AbstractDaemonTest {
-  @Inject private ChangeOperations changeOperations;
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.DISABLE_CHANGE_ETAGS)
-  public void changeEtagsDisabled() throws Exception {
-    Change.Id changeId = changeOperations.newChange().create();
-
-    RestResponse response = adminRestSession.get(String.format("/changes/%s", changeId));
-    assertThat(response.getHeader(HttpHeaders.ETAG)).isNull();
-
-    response = adminRestSession.get(String.format("/changes/%s/detail", changeId));
-    assertThat(response.getHeader(HttpHeaders.ETAG)).isNull();
-  }
-
-  @Test
-  public void changeEtagsEnabled() throws Exception {
-    Change.Id changeId = changeOperations.newChange().create();
-
-    RestResponse response = adminRestSession.get(String.format("/changes/%s", changeId));
-    assertThat(response.getHeader(HttpHeaders.ETAG)).isNotNull();
-
-    response = adminRestSession.get(String.format("/changes/%s/detail", changeId));
-    assertThat(response.getHeader(HttpHeaders.ETAG)).isNotNull();
-  }
-}
diff --git a/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java b/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
index 7eec5ea..cd1ae5af 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
@@ -146,15 +146,11 @@
     testRepo.reset(c1_1);
     pushHead(testRepo, "refs/for/master", false);
     PatchSet.Id ps1_1 = getPatchSetId(c1_1);
-    String oldETag = changes.parse(ps1_1.changeId()).getETag();
 
     testRepo.reset(c2_1);
     pushHead(testRepo, "refs/for/master", false);
     PatchSet.Id ps2_1 = getPatchSetId(c2_1);
 
-    // Push of change 2 should not affect groups (or anything else) of change 1.
-    assertThat(changes.parse(ps1_1.changeId()).getETag()).isEqualTo(oldETag);
-
     for (PatchSet.Id ps : ImmutableList.of(ps2_1, ps1_1)) {
       assertRelated(ps, changeAndCommit(ps2_1, c2_1, 1), changeAndCommit(ps1_1, c1_1, 1));
     }
diff --git a/javatests/com/google/gerrit/extensions/BUILD b/javatests/com/google/gerrit/extensions/BUILD
index 1bb39c8..86fd295 100644
--- a/javatests/com/google/gerrit/extensions/BUILD
+++ b/javatests/com/google/gerrit/extensions/BUILD
@@ -8,6 +8,7 @@
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/extensions/common/testing:common-test-util",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:guava",
         "//lib/guice",
         "//lib/truth",
diff --git a/javatests/com/google/gerrit/extensions/registration/DynamicItemTest.java b/javatests/com/google/gerrit/extensions/registration/DynamicItemTest.java
new file mode 100644
index 0000000..828f6c1
--- /dev/null
+++ b/javatests/com/google/gerrit/extensions/registration/DynamicItemTest.java
@@ -0,0 +1,175 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.registration;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.common.Nullable;
+import com.google.inject.AbstractModule;
+import com.google.inject.Binder;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import com.google.inject.ProvisionException;
+import com.google.inject.TypeLiteral;
+import java.util.function.Consumer;
+import org.junit.Test;
+
+public class DynamicItemTest {
+  private static final String PLUGIN_NAME = "plugin-name";
+
+  private static final String UNEXPECTED_PLUGIN_NAME = "unexpected-plugin";
+  private static final String DYNAMIC_ITEM_1 = "item-1";
+  private static final String DYNAMIC_ITEM_2 = "item-2";
+  private static final TypeLiteral<String> STRING_TYPE_LITERAL = new TypeLiteral<>() {};
+  private static final TypeLiteral<FinalItemApi> FINAL_ITEM_API_TYPE_LITERAL =
+      new TypeLiteral<>() {};
+  private static final TypeLiteral<FinalItemApiForPlugin>
+      FINAL_TARGET_PLUGIN_ITEM_API_TYPE_LITERAL = new TypeLiteral<>() {};
+
+  @DynamicItem.Final
+  private interface FinalItemApi {}
+
+  private static class FinalItemImpl implements FinalItemApi {
+    private static final FinalItemApi INSTANCE = new FinalItemImpl();
+  }
+
+  @DynamicItem.Final(implementedByPlugin = PLUGIN_NAME)
+  private interface FinalItemApiForPlugin {}
+
+  private static class FinalItemImplByPlugin implements FinalItemApiForPlugin {
+    private static final FinalItemApiForPlugin INSTANCE = new FinalItemImplByPlugin();
+  }
+
+  @Test
+  public void shouldAssignDynamicItemTwice() {
+    ImmutableMap<TypeLiteral<?>, DynamicItem<?>> bindings =
+        ImmutableMap.of(STRING_TYPE_LITERAL, DynamicItem.itemOf(String.class, null));
+
+    ImmutableList<RegistrationHandle> gerritRegistrations =
+        PrivateInternals_DynamicTypes.attachItems(
+            newInjector(
+                (binder) -> {
+                  DynamicItem.itemOf(binder, String.class);
+                  DynamicItem.bind(binder, String.class).toInstance(DYNAMIC_ITEM_1);
+                }),
+            PluginName.GERRIT,
+            bindings);
+    assertThat(gerritRegistrations).hasSize(1);
+    assertDynamicItem(bindings.get(STRING_TYPE_LITERAL), DYNAMIC_ITEM_1, PluginName.GERRIT);
+
+    ImmutableList<RegistrationHandle> pluginRegistrations =
+        PrivateInternals_DynamicTypes.attachItems(
+            newInjector(
+                (binder) -> DynamicItem.bind(binder, String.class).toInstance(DYNAMIC_ITEM_2)),
+            PLUGIN_NAME,
+            bindings);
+    assertThat(pluginRegistrations).hasSize(1);
+    assertDynamicItem(bindings.get(STRING_TYPE_LITERAL), DYNAMIC_ITEM_2, PLUGIN_NAME);
+  }
+
+  @Test
+  public void shouldFailToAssignFinalDynamicItemTwice() {
+    ImmutableMap<TypeLiteral<?>, DynamicItem<?>> bindings =
+        ImmutableMap.of(FINAL_ITEM_API_TYPE_LITERAL, DynamicItem.itemOf(FinalItemApi.class, null));
+
+    ImmutableList<RegistrationHandle> baseInjectorRegistrations =
+        PrivateInternals_DynamicTypes.attachItems(
+            newInjector(
+                (binder) -> {
+                  DynamicItem.itemOf(binder, FinalItemApi.class);
+                  DynamicItem.bind(binder, FinalItemApi.class).toInstance(FinalItemImpl.INSTANCE);
+                }),
+            PluginName.GERRIT,
+            bindings);
+    assertThat(baseInjectorRegistrations).hasSize(1);
+
+    ProvisionException ignored =
+        assertThrows(
+            ProvisionException.class,
+            () -> {
+              ImmutableList<RegistrationHandle> unused =
+                  PrivateInternals_DynamicTypes.attachItems(
+                      newInjector(
+                          (binder) ->
+                              DynamicItem.bind(binder, FinalItemApi.class)
+                                  .toInstance(FinalItemImpl.INSTANCE)),
+                      PluginName.GERRIT,
+                      bindings);
+            });
+  }
+
+  @Test
+  public void shouldFailToAssignFinalDynamicItemToDifferentPlugin() {
+    ImmutableMap<TypeLiteral<?>, DynamicItem<?>> bindings =
+        ImmutableMap.of(
+            FINAL_TARGET_PLUGIN_ITEM_API_TYPE_LITERAL,
+            DynamicItem.itemOf(FinalItemApi.class, null));
+
+    assertThrows(
+        ProvisionException.class,
+        () -> {
+          ImmutableList<RegistrationHandle> unused =
+              PrivateInternals_DynamicTypes.attachItems(
+                  newInjector(
+                      (binder) ->
+                          DynamicItem.bind(binder, FinalItemApiForPlugin.class)
+                              .toInstance(FinalItemImplByPlugin.INSTANCE)),
+                  UNEXPECTED_PLUGIN_NAME,
+                  bindings);
+        });
+  }
+
+  @Test
+  public void shouldAssignFinalDynamicItemToExpectedPlugin() {
+    ImmutableMap<TypeLiteral<?>, DynamicItem<?>> bindings =
+        ImmutableMap.of(
+            FINAL_TARGET_PLUGIN_ITEM_API_TYPE_LITERAL,
+            DynamicItem.itemOf(FinalItemApi.class, null));
+
+    ImmutableList<RegistrationHandle> pluginRegistrations =
+        PrivateInternals_DynamicTypes.attachItems(
+            newInjector(
+                (binder) ->
+                    DynamicItem.bind(binder, FinalItemApiForPlugin.class)
+                        .toInstance(FinalItemImplByPlugin.INSTANCE)),
+            PLUGIN_NAME,
+            bindings);
+    assertThat(pluginRegistrations).hasSize(1);
+    assertDynamicItem(
+        bindings.get(FINAL_TARGET_PLUGIN_ITEM_API_TYPE_LITERAL),
+        FinalItemImplByPlugin.INSTANCE,
+        PLUGIN_NAME);
+  }
+
+  private static Injector newInjector(Consumer<Binder> binding) {
+    return Guice.createInjector(
+        new AbstractModule() {
+          @Override
+          protected void configure() {
+            binding.accept(binder());
+          }
+        });
+  }
+
+  private static <T> void assertDynamicItem(
+      @Nullable DynamicItem<?> item, T itemVal, String pluginName) {
+    assertThat(item).isNotNull();
+    assertThat(item.get()).isEqualTo(itemVal);
+    assertThat(item.getPluginName()).isEqualTo(pluginName);
+  }
+}
diff --git a/plugins/replication b/plugins/replication
index aacb8b2..1c16901 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit aacb8b2a20267e88ccda811d27293bac66e2006b
+Subproject commit 1c16901e971c96c20705b272703ad59b9b46200a
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index ed42b3b..c02d474 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -145,7 +145,6 @@
   changeViewModelToken,
   ChangeViewState,
   createChangeUrl,
-  createEditUrl,
 } from '../../../models/views/change';
 import {rootUrl} from '../../../utils/url-util';
 import {userModelToken} from '../../../models/user/user-model';
@@ -2399,13 +2398,10 @@
         controls.openDeleteDialog(path);
         break;
       case GrEditConstants.Actions.OPEN.id:
-        assertIsDefined(this.patchNum, 'patchset number');
         this.getNavigation().setUrl(
-          createEditUrl({
-            changeNum: this.change._number,
-            repo: this.change.project,
-            patchNum: this.patchNum,
+          this.getViewModel().editUrl({
             editView: {path},
+            patchNum: this.patchNum,
           })
         );
         break;
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
index 1fd4aa2..7b9c291 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
@@ -73,7 +73,10 @@
 import {Modifier} from '../../../utils/dom-util';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrCopyLinks} from '../gr-copy-links/gr-copy-links';
-import {ChangeChildView} from '../../../models/views/change';
+import {
+  ChangeChildView,
+  changeViewModelToken,
+} from '../../../models/views/change';
 import {rootUrl} from '../../../utils/url-util';
 import {testResolver} from '../../../test/common-test-setup';
 import {UserModel, userModelToken} from '../../../models/user/user-model';
@@ -308,6 +311,12 @@
 
   setup(async () => {
     setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
+    sinon
+      .stub(testResolver(changeViewModelToken), 'editUrl')
+      .returns('fakeEditUrl');
+    sinon
+      .stub(testResolver(changeViewModelToken), 'diffUrl')
+      .returns('fakeDiffUrl');
 
     stubRestApi('getConfig').returns(
       Promise.resolve({
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
index 7a09518..3ecbf74 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
@@ -80,9 +80,8 @@
 import {incrementalRepeat} from '../../lit/incremental-repeat';
 import {ifDefined} from 'lit/directives/if-defined.js';
 import {
-  createDiffUrl,
-  createEditUrl,
   createChangeUrl,
+  changeViewModelToken,
 } from '../../../models/views/change';
 import {userModelToken} from '../../../models/user/user-model';
 import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
@@ -212,7 +211,7 @@
   diffViewMode?: DiffViewMode;
 
   @property({type: Boolean})
-  editMode?: boolean;
+  editMode = false;
 
   private _filesExpanded = FilesExpandedState.NONE;
 
@@ -313,6 +312,8 @@
 
   private readonly getNavigation = resolve(this, navigationToken);
 
+  private readonly getViewModel = resolve(this, changeViewModelToken);
+
   // private but used in test
   fileCursor = new GrCursorManager();
 
@@ -2167,15 +2168,13 @@
   // Private but used in tests.
   openCursorFile() {
     const diff = this.diffCursor?.getTargetDiffElement();
-    if (!this.change || !diff || !this.patchRange || !diff.path) {
-      throw new Error('change, diff and patchRange must be all set and valid');
+    if (!this.change || !diff || !this.patchNum || !diff.path) {
+      throw new Error('change, diff and pacthNum must be all set and valid');
     }
     this.getNavigation().setUrl(
-      createDiffUrl({
-        change: this.change,
-        patchNum: this.patchRange.patchNum,
-        basePatchNum: this.patchRange.basePatchNum,
+      this.getViewModel().diffUrl({
         diffView: {path: diff.path},
+        patchNum: this.patchNum,
       })
     );
   }
@@ -2188,15 +2187,13 @@
     if (!this.files[this.fileCursor.index]) {
       return;
     }
-    if (!this.change || !this.patchRange) {
+    if (!this.change || !this.patchNum) {
       throw new Error('change and patchRange must be set');
     }
     this.getNavigation().setUrl(
-      createDiffUrl({
-        change: this.change,
-        patchNum: this.patchRange.patchNum,
-        basePatchNum: this.patchRange.basePatchNum,
+      this.getViewModel().diffUrl({
         diffView: {path: this.files[this.fileCursor.index].__path},
+        patchNum: this.patchNum,
       })
     );
   }
@@ -2214,30 +2211,21 @@
     );
   }
 
+  /** Returns an edit or diff URL depending on `editMode`. */
   // Private but used in tests
-  computeDiffURL(path?: string) {
-    if (
-      this.change === undefined ||
-      this.patchRange?.patchNum === undefined ||
-      path === undefined ||
-      this.editMode === undefined
-    ) {
-      return;
-    }
+  computeDiffURL(path?: string): string | undefined {
+    if (path === undefined) return;
+    if (this.patchNum === undefined) return;
+
     if (this.editMode && path !== SpecialFilePath.MERGE_LIST) {
-      return createEditUrl({
-        changeNum: this.change._number,
-        repo: this.change.project,
-        patchNum: this.patchRange.patchNum,
+      return this.getViewModel().editUrl({
+        patchNum: this.patchNum,
         editView: {path},
       });
     }
-    return createDiffUrl({
-      changeNum: this.change._number,
-      repo: this.change.project,
-      patchNum: this.patchRange.patchNum,
-      basePatchNum: this.patchRange.basePatchNum,
+    return this.getViewModel().diffUrl({
       diffView: {path},
+      patchNum: this.patchNum,
     });
   }
 
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
index eec829a..ca7ab14 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
@@ -60,6 +60,11 @@
 import {FileMode} from '../../../utils/file-util';
 import {SinonStubbedMember} from 'sinon';
 import {GrDiffCursor} from '../../../embed/diff/gr-diff-cursor/gr-diff-cursor';
+import {
+  ChangeChildView,
+  changeViewModelToken,
+} from '../../../models/views/change';
+import {GerritView} from '../../../services/router/router-model';
 
 suite('gr-diff a11y test', () => {
   test('audit', async () => {
@@ -79,6 +84,15 @@
   let element: GrFileList;
   let saveStub: sinon.SinonStub;
 
+  setup(async () => {
+    testResolver(changeViewModelToken).setState({
+      view: GerritView.CHANGE,
+      childView: ChangeChildView.OVERVIEW,
+      changeNum: 42 as NumericChangeId,
+      repo: 'gerrit' as RepoName,
+    });
+  });
+
   suite('basic tests', async () => {
     setup(async () => {
       stubRestApi('getDiffComments').returns(Promise.resolve({}));
@@ -179,7 +193,7 @@
             <gr-file-status></gr-file-status>
           </div>
           <span class="path" role="gridcell">
-            <a class="pathLink">
+            <a class="pathLink" href="/c/gerrit/+/42/2/path/file0">
               <span class="fullFileName" title="path/file0">
                 <span class="newFilePath"> path/ </span>
                 <span class="fileName"> file0 </span>
@@ -299,7 +313,7 @@
         fileRows[0].querySelector('.path'),
         /* HTML */ `
           <span class="path" role="gridcell">
-            <a class="pathLink">
+            <a class="pathLink" href="/c/gerrit/+/42/2/path/file0">
               <span class="fullFileName" title="path/file0">
                 <span class="newFilePath"> path/ </span>
                 <span class="fileName"> file0 </span>
@@ -317,7 +331,7 @@
         fileRows[1].querySelector('.path'),
         /* HTML */ `
           <span class="path" role="gridcell">
-            <a class="pathLink">
+            <a class="pathLink" href="/c/gerrit/+/42/2/path/file1">
               <span class="fullFileName" title="path/file1">
                 <span class="matchingFilePath"> path/ </span>
                 <span class="fileName"> file1 </span>
@@ -947,10 +961,10 @@
         ];
         element.changeNum = 42 as NumericChangeId;
         element.basePatchNum = PARENT;
-        element.patchNum = 2 as RevisionPatchSetNum;
+        element.patchNum = 1 as RevisionPatchSetNum;
         element.change = {
           _number: 42 as NumericChangeId,
-          project: 'test-project',
+          project: 'gerrit',
         } as ParsedChangeInfo;
         element.fileCursor.setCursorAtIndex(0);
         await element.updateComplete;
@@ -1009,7 +1023,7 @@
         assert.equal(setUrlStub.callCount, 1);
         assert.equal(
           setUrlStub.lastCall.firstArg,
-          '/c/test-project/+/42/2/file_added_in_rev2.txt'
+          '/c/gerrit/+/42/1/file_added_in_rev2.txt'
         );
 
         pressKey(element, 'k');
@@ -1705,35 +1719,25 @@
 
   suite('diff url file list', () => {
     test('diff url', () => {
-      element.change = {
-        ...createParsedChange(),
-        _number: 1 as NumericChangeId,
-        project: 'gerrit' as RepoName,
-      };
-      element.basePatchNum = PARENT;
-      element.patchNum = 1 as RevisionPatchSetNum;
       const path = 'index.php';
+      element.patchNum = 1 as RevisionPatchSetNum;
       element.editMode = false;
-      assert.equal(element.computeDiffURL(path), '/c/gerrit/+/1/1/index.php');
+      assert.equal(element.computeDiffURL(path), '/c/gerrit/+/42/1/index.php');
     });
 
     test('diff url commit msg', () => {
-      element.change = {
-        ...createParsedChange(),
-        _number: 1 as NumericChangeId,
-        project: 'gerrit' as RepoName,
-      };
-      element.basePatchNum = PARENT;
-      element.patchNum = 1 as RevisionPatchSetNum;
-      element.editMode = false;
       const path = '/COMMIT_MSG';
-      assert.equal(element.computeDiffURL(path), '/c/gerrit/+/1/1//COMMIT_MSG');
+      element.editMode = false;
+      assert.equal(
+        element.computeDiffURL(path),
+        '/c/gerrit/+/42/1//COMMIT_MSG'
+      );
     });
 
     test('edit url', () => {
       element.change = {
         ...createParsedChange(),
-        _number: 1 as NumericChangeId,
+        _number: 42 as NumericChangeId,
         project: 'gerrit' as RepoName,
       };
       element.basePatchNum = PARENT;
@@ -1742,14 +1746,14 @@
       const path = 'index.php';
       assert.equal(
         element.computeDiffURL(path),
-        '/c/gerrit/+/1/1/index.php,edit'
+        '/c/gerrit/+/42/1/index.php,edit'
       );
     });
 
     test('edit url commit msg', () => {
       element.change = {
         ...createParsedChange(),
-        _number: 1 as NumericChangeId,
+        _number: 42 as NumericChangeId,
         project: 'gerrit' as RepoName,
       };
       element.basePatchNum = PARENT;
@@ -1758,7 +1762,7 @@
       const path = '/COMMIT_MSG';
       assert.equal(
         element.computeDiffURL(path),
-        '/c/gerrit/+/1/1//COMMIT_MSG,edit'
+        '/c/gerrit/+/42/1//COMMIT_MSG,edit'
       );
     });
   });
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index 72d3268..c5194d5 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -58,6 +58,7 @@
 import {
   DropdownLink,
   LabelNameToInfoMap,
+  PARENT,
   PatchSetNumber,
 } from '../../types/common';
 import {spinnerStyles} from '../../styles/gr-spinner-styles';
@@ -78,7 +79,7 @@
 import {when} from 'lit/directives/when.js';
 import {DropdownItem} from '../shared/gr-dropdown-list/gr-dropdown-list';
 import './gr-checks-attempt';
-import {createDiffUrl, changeViewModelToken} from '../../models/views/change';
+import {changeViewModelToken} from '../../models/views/change';
 import {formStyles} from '../../styles/form-styles';
 
 /**
@@ -635,6 +636,8 @@
 
   private getChangeModel = resolve(this, changeModelToken);
 
+  private readonly getViewModel = resolve(this, changeViewModelToken);
+
   static override get styles() {
     return [
       sharedStyles,
@@ -714,14 +717,13 @@
       const change = this.getChangeModel().getChange();
       assertIsDefined(change);
       const path = pointer.path;
-      const patchset = this.result?.patchset as PatchSetNumber | undefined;
+      const patchset = this.result?.patchset as PatchSetNumber;
       const line = pointer?.range?.start_line;
       return {
         icon: LinkIcon.CODE,
         tooltip: `${path}${rangeText}`,
-        url: createDiffUrl({
-          changeNum: change._number,
-          repo: change.project,
+        url: this.getViewModel().diffUrl({
+          basePatchNum: PARENT,
           patchNum: patchset,
           checksPatchset: patchset,
           diffView: {path, lineNum: line},
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
index ee2a806..a5c2a99 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
@@ -92,7 +92,6 @@
 import {when} from 'lit/directives/when.js';
 import {styleMap} from 'lit/directives/style-map.js';
 import {
-  createDiffUrl,
   ChangeChildView,
   changeViewModelToken,
 } from '../../../models/views/change';
@@ -1457,7 +1456,11 @@
     if (!newPath) return;
     if (newPath.up) return this.getChangeModel().changeUrl();
     if (!newPath.path) return;
-    return this.getChangeModel().diffUrl({path: newPath.path});
+    if (!this.patchNum) return;
+    return this.getViewModel().diffUrl({
+      diffView: {path: newPath.path},
+      patchNum: this.patchNum,
+    });
   }
 
   private goToEditFile() {
@@ -1504,20 +1507,14 @@
   }
 
   private updateUrlToDiffUrl(lineNum?: number, leftSide?: boolean) {
-    if (!this.change) return;
-    if (!this.patchNum) return;
-    if (!this.changeNum) return;
-    if (!this.path) return;
-    const url = createDiffUrl({
-      changeNum: this.changeNum,
-      repo: this.change.project,
-      patchNum: this.patchNum,
-      basePatchNum: this.basePatchNum,
+    if (!this.path || !this.patchNum) return;
+    const url = this.getViewModel().diffUrl({
       diffView: {
         path: this.path,
         lineNum,
         leftSide,
       },
+      patchNum: this.patchNum,
     });
     history.replaceState(null, '', url);
   }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
index 93424b1..96467b4 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
@@ -137,9 +137,9 @@
       stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
       stubRestApi('getPortedComments').returns(Promise.resolve({}));
 
-      element = await fixture(html`<gr-diff-view></gr-diff-view>`);
       viewModel = testResolver(changeViewModelToken);
       viewModel.setState(createDiffViewState());
+      element = await fixture(html`<gr-diff-view></gr-diff-view>`);
       await waitUntil(() => element.changeNum === TEST_NUMERIC_CHANGE_ID);
       element.path = 'some/path.txt';
       element.change = createParsedChange();
@@ -188,16 +188,18 @@
 
     test('renders', async () => {
       browserModel.setScreenWidth(0);
-      element.patchNum = 10 as RevisionPatchSetNum;
+      const patchNum = 10 as RevisionPatchSetNum;
+      element.patchNum = patchNum;
       element.basePatchNum = PARENT;
       const change = {
         ...createParsedChange(),
         _number: 42 as NumericChangeId,
         revisions: {
-          a: createRevision(10),
+          a: createRevision(patchNum),
         },
       };
       changeModel.updateStateChange(change);
+      viewModel.updateState({patchNum});
       element.files = getFilesFromFileList([
         'chell.go',
         'glados.txt',
@@ -1012,14 +1014,16 @@
       });
 
       test('prev/up/next links', async () => {
+        const patchNum = 10 as RevisionPatchSetNum;
         viewModel.setState({
           ...createDiffViewState(),
+          patchNum,
         });
         const change = {
           ...createParsedChange(),
           _number: 42 as NumericChangeId,
           revisions: {
-            a: createRevision(10),
+            a: createRevision(patchNum),
           },
         };
         changeModel.updateStateChange(change);
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
index 1e871ce..ec8faae 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
@@ -29,7 +29,7 @@
 import {customElement, property, query, state} from 'lit/decorators.js';
 import {BindValueChangeEvent} from '../../../types/events';
 import {IronInputElement} from '@polymer/iron-input/iron-input';
-import {createEditUrl} from '../../../models/views/change';
+import {changeViewModelToken} from '../../../models/views/change';
 import {resolve} from '../../../models/dependency';
 import {modalStyles} from '../../../styles/gr-modal-styles';
 import {whenVisible} from '../../../utils/dom-util';
@@ -81,6 +81,8 @@
 
   private readonly getChangeModel = resolve(this, changeModelToken);
 
+  private readonly getViewModel = resolve(this, changeViewModelToken);
+
   private readonly getNavigation = resolve(this, navigationToken);
 
   static override get styles() {
@@ -431,13 +433,10 @@
       return;
     }
     assertIsDefined(this.patchNum, 'patchset number');
-    const url = createEditUrl({
-      changeNum: this.change._number,
-      repo: this.change.project,
-      patchNum: this.patchNum,
+    const url = this.getViewModel().editUrl({
       editView: {path: this.path},
+      patchNum: this.patchNum,
     });
-
     this.getNavigation().setUrl(url);
     this.closeDialog(this.getDialogFromEvent(e));
   };
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.ts b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.ts
index 0e6778a..aacbb36 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.ts
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.ts
@@ -32,6 +32,11 @@
   changeModelToken,
 } from '../../../models/change/change-model';
 import {SinonStubbedMember} from 'sinon';
+import {
+  ChangeChildView,
+  changeViewModelToken,
+} from '../../../models/views/change';
+import {GerritView} from '../../../services/router/router-model';
 
 suite('gr-edit-controls tests', () => {
   let element: GrEditControls;
@@ -45,6 +50,13 @@
   >;
 
   setup(async () => {
+    testResolver(changeViewModelToken).setState({
+      view: GerritView.CHANGE,
+      childView: ChangeChildView.OVERVIEW,
+      changeNum: 42 as NumericChangeId,
+      repo: 'gerrit' as RepoName,
+    });
+
     element = await fixture<GrEditControls>(html`
       <gr-edit-controls></gr-edit-controls>
     `);
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
index 0b03581..8787d3c 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
@@ -70,7 +70,11 @@
 import {commentsModelToken} from '../../../models/comments/comments-model';
 import {changeModelToken} from '../../../models/change/change-model';
 import {whenRendered} from '../../../utils/dom-util';
-import {createChangeUrl, createDiffUrl} from '../../../models/views/change';
+import {
+  changeViewModelToken,
+  createChangeUrl,
+  createDiffUrl,
+} from '../../../models/views/change';
 import {userModelToken} from '../../../models/user/user-model';
 import {highlightServiceToken} from '../../../services/highlight/highlight-service';
 import {noAwait, waitUntil} from '../../../utils/async-util';
@@ -248,6 +252,8 @@
 
   private readonly getUserModel = resolve(this, userModelToken);
 
+  private readonly getViewModel = resolve(this, changeViewModelToken);
+
   private readonly shortcuts = new ShortcutController(this);
 
   private readonly syntaxLayer = new GrSyntaxLayerWorker(
@@ -706,12 +712,15 @@
   }
 
   private getDiffUrlForPath() {
-    if (!this.changeNum || !this.repoName || !this.thread?.path) {
+    if (
+      !this.changeNum ||
+      !this.repoName ||
+      !this.thread?.path ||
+      !this.thread?.patchNum
+    ) {
       return undefined;
     }
-    return createDiffUrl({
-      changeNum: this.changeNum,
-      repo: this.repoName,
+    return this.getViewModel().diffUrl({
       patchNum: this.thread.patchNum,
       diffView: {path: this.thread.path},
     });
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
index 571a322..b56fcfd 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
@@ -38,6 +38,11 @@
   commentsModelToken,
 } from '../../../models/comments/comments-model';
 import {testResolver} from '../../../test/common-test-setup';
+import {
+  ChangeChildView,
+  changeViewModelToken,
+} from '../../../models/views/change';
+import {GerritView} from '../../../services/router/router-model';
 
 const c1: CommentInfo = {
   author: {name: 'Kermit'},
@@ -80,6 +85,12 @@
   let element: GrCommentThread;
 
   setup(async () => {
+    testResolver(changeViewModelToken).setState({
+      view: GerritView.CHANGE,
+      childView: ChangeChildView.OVERVIEW,
+      changeNum: 1 as NumericChangeId,
+      repo: 'test-repo-name' as RepoName,
+    });
     stubRestApi('getLoggedIn').returns(Promise.resolve(false));
     element = await fixture(html`<gr-comment-thread></gr-comment-thread>`);
     element.changeNum = 1 as NumericChangeId;
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
index 3964422..eeccda6 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
@@ -43,7 +43,7 @@
 import {storageServiceToken} from '../../../services/storage/gr-storage_impl';
 import {resolve} from '../../../models/dependency';
 import {formStyles} from '../../../styles/form-styles';
-import {createEditUrl} from '../../../models/views/change';
+import {changeViewModelToken} from '../../../models/views/change';
 import {SpecialFilePath} from '../../../constants/constants';
 
 const RESTORED_MESSAGE = 'Content restored from a previous edit.';
@@ -128,6 +128,8 @@
 
   private readonly getNavigation = resolve(this, navigationToken);
 
+  private readonly getViewModel = resolve(this, changeViewModelToken);
+
   // Tests use this so needs to be non private
   storeTask?: DelayedTask;
 
@@ -500,12 +502,11 @@
     if (this.editMode) {
       assertIsDefined(this.changeNum, 'changeNum');
       assertIsDefined(this.repoName, 'repoName');
+      assertIsDefined(this.patchNum, 'patchNum');
       this.getNavigation().setUrl(
-        createEditUrl({
-          changeNum: this.changeNum,
-          repo: this.repoName,
-          patchNum: this.patchNum,
+        this.getViewModel().editUrl({
           editView: {path: SpecialFilePath.COMMIT_MESSAGE},
+          patchNum: this.patchNum,
         })
       );
 
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts
index e8ccdd1..dbb89db 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts
@@ -19,6 +19,7 @@
   RepoName,
   RevisionPatchSetNum,
 } from '../../../api/rest-api';
+import {changeViewModelToken} from '../../../models/views/change';
 
 const emails = [
   {
@@ -189,6 +190,11 @@
 
     suite('in editMode', () => {
       test('click opens edit url', async () => {
+        const editUrlStub = sinon.stub(
+          testResolver(changeViewModelToken),
+          'editUrl'
+        );
+        editUrlStub.returns('fakeUrl');
         const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
         element.editMode = true;
         element.changeNum = 42 as NumericChangeId;
@@ -201,10 +207,7 @@
         );
         editButton.click();
         assert.isTrue(setUrlStub.called);
-        assert.equal(
-          setUrlStub.lastCall.args[0],
-          '/c/Test+Repo/+/42/1//COMMIT_MSG,edit'
-        );
+        assert.equal(setUrlStub.lastCall.args[0], 'fakeUrl');
       });
     });
   });
diff --git a/polygerrit-ui/app/models/change/change-model.ts b/polygerrit-ui/app/models/change/change-model.ts
index 373f199..04fa0d6 100644
--- a/polygerrit-ui/app/models/change/change-model.ts
+++ b/polygerrit-ui/app/models/change/change-model.ts
@@ -46,8 +46,6 @@
   ChangeChildView,
   ChangeViewModel,
   createChangeUrl,
-  createDiffUrl,
-  createEditUrl,
 } from '../views/change';
 import {NavigationService} from '../../elements/core/gr-navigation/gr-navigation';
 import {getRevertCreatedChangeIds} from '../../utils/message-util';
@@ -657,27 +655,13 @@
     return this.getState().change;
   }
 
-  diffUrl(
-    diffView: {path: string; lineNum?: number},
-    patchNum = this.patchNum,
-    basePatchNum = this.basePatchNum
-  ) {
-    if (!this.change) return;
-    if (!this.patchNum) return;
-    return createDiffUrl({
-      change: this.change,
-      patchNum,
-      basePatchNum,
-      diffView,
-    });
-  }
-
   navigateToDiff(
     diffView: {path: string; lineNum?: number},
     patchNum = this.patchNum,
     basePatchNum = this.basePatchNum
   ) {
-    const url = this.diffUrl(diffView, patchNum, basePatchNum);
+    if (!patchNum) return;
+    const url = this.viewModel.diffUrl({diffView, patchNum, basePatchNum});
     if (!url) return;
     this.navigation.setUrl(url);
   }
@@ -715,18 +699,9 @@
     this.navigation.setUrl(url);
   }
 
-  editUrl(editView: {path: string; lineNum?: number}) {
-    if (!this.change) return;
-    return createEditUrl({
-      changeNum: this.change._number,
-      repo: this.change.project,
-      patchNum: this.patchNum,
-      editView,
-    });
-  }
-
   navigateToEdit(editView: {path: string; lineNum?: number}) {
-    const url = this.editUrl(editView);
+    if (!this.patchNum) return;
+    const url = this.viewModel.editUrl({editView, patchNum: this.patchNum});
     if (!url) return;
     this.navigation.setUrl(url);
   }
diff --git a/polygerrit-ui/app/models/views/change.ts b/polygerrit-ui/app/models/views/change.ts
index 06d981a..661c74f 100644
--- a/polygerrit-ui/app/models/views/change.ts
+++ b/polygerrit-ui/app/models/views/change.ts
@@ -11,11 +11,12 @@
   ChangeInfo,
   PatchSetNumber,
   EDIT,
+  PARENT,
 } from '../../api/rest-api';
 import {Tab} from '../../constants/constants';
 import {GerritView} from '../../services/router/router-model';
 import {UrlEncodedCommentId} from '../../types/common';
-import {toggleSet} from '../../utils/common-util';
+import {assertIsDefined, toggleSet} from '../../utils/common-util';
 import {select} from '../../utils/observable-util';
 import {
   encodeURL,
@@ -26,6 +27,7 @@
 import {define} from '../dependency';
 import {Model} from '../base/model';
 import {ViewState} from './base';
+import {isNumber} from '../../utils/patch-set-util';
 
 export enum ChangeChildView {
   OVERVIEW = 'OVERVIEW',
@@ -85,7 +87,7 @@
 
   /** These properties apply to the DIFF child view only. */
   diffView?: {
-    path?: string;
+    path: string;
     // TODO: Use LineNumber as a type, i.e. accept FILE and LOST.
     lineNum?: number;
     leftSide?: boolean;
@@ -93,11 +95,28 @@
 
   /** These properties apply to the EDIT child view only. */
   editView?: {
-    path?: string;
+    path: string;
     lineNum?: number;
   };
 }
 
+export type DiffViewState = Partial<ChangeViewState> & {
+  patchNum: RevisionPatchSetNum;
+  diffView: {
+    path: string;
+    lineNum?: number;
+    leftSide?: boolean;
+  };
+};
+
+export type EditViewState = Partial<ChangeViewState> & {
+  patchNum: RevisionPatchSetNum;
+  editView: {
+    path: string;
+    lineNum?: number;
+  };
+};
+
 /**
  * This is a convenience type such that you can pass a `ChangeInfo` object
  * as the `change` property instead of having to set both the `changeNum` and
@@ -145,7 +164,7 @@
 
 export function createChangeUrl(
   obj: CreateChangeUrlObject | Omit<ChangeViewState, 'view' | 'childView'>
-) {
+): string {
   const state: ChangeViewState = objToState({
     ...obj,
     childView: ChangeChildView.OVERVIEW,
@@ -198,7 +217,7 @@
 
 export function createDiffUrl(
   obj: CreateChangeUrlObject | Omit<ChangeViewState, 'view' | 'childView'>
-) {
+): string {
   const state: ChangeViewState = objToState({
     ...obj,
     childView: ChangeChildView.DIFF,
@@ -378,6 +397,49 @@
     }
   }
 
+  /**
+   * Wrapper around createDiffUrl() that falls back to the current state for all
+   * properties that are not explicitly provided as an override.
+   */
+  diffUrl(override: DiffViewState): string {
+    const current = this.getState();
+    assertIsDefined(current?.changeNum);
+    assertIsDefined(current?.repo);
+
+    const patchNum = override.patchNum ?? current.patchNum;
+    let basePatchNum = override.basePatchNum ?? current.basePatchNum;
+    if (isNumber(basePatchNum) && isNumber(patchNum)) {
+      if ((patchNum as number) <= (basePatchNum as number)) {
+        basePatchNum = PARENT;
+      }
+    }
+    return createDiffUrl({
+      changeNum: override.changeNum ?? current.changeNum,
+      repo: override.repo ?? current.repo,
+      patchNum,
+      basePatchNum,
+      checksPatchset: override.checksPatchset ?? current.checksPatchset,
+      diffView: override.diffView ?? current.diffView,
+    });
+  }
+
+  /**
+   * Wrapper around createEditUrl() that falls back to the current state for all
+   * properties that are not explicitly provided as an override.
+   */
+  editUrl(override: EditViewState): string {
+    const current = this.getState();
+    assertIsDefined(current?.changeNum);
+    assertIsDefined(current?.repo);
+
+    return createEditUrl({
+      changeNum: override.changeNum ?? current.changeNum,
+      repo: override.repo ?? current.repo,
+      patchNum: override.patchNum ?? current.patchNum,
+      editView: override.editView ?? current.editView,
+    });
+  }
+
   toggleSelectedCheckRun(checkName: string) {
     const current = this.getState()?.checksRunsSelected ?? new Set();
     const next = new Set(current);
diff --git a/polygerrit-ui/app/utils/patch-set-util.ts b/polygerrit-ui/app/utils/patch-set-util.ts
index 7a5cd45..dc7adc1 100644
--- a/polygerrit-ui/app/utils/patch-set-util.ts
+++ b/polygerrit-ui/app/utils/patch-set-util.ts
@@ -70,7 +70,7 @@
   return patchset as PatchSetNum;
 }
 
-export function isNumber(psn: PatchSetNum): psn is PatchSetNumber {
+export function isNumber(psn?: PatchSetNum): psn is PatchSetNumber {
   return typeof psn === 'number';
 }