diff --git a/.chachalog/Kp7mQx2R.md b/.chachalog/Kp7mQx2R.md
index fcf32e38..198f3ac8 100644
--- a/.chachalog/Kp7mQx2R.md
+++ b/.chachalog/Kp7mQx2R.md
@@ -3,6 +3,8 @@
javascript-modules: minor
---
-`buildNodeUrl` now autocollects cache dependencies for the content it links to, preventing 404 errors when the linked content is renamed or moved. (#783)
+`buildNodeUrl` now autocollects cache dependencies for the content it links to, preventing 404 errors when the linked content is renamed or moved. (#783, #810)
This mechanism is enabled by default but can be disabled on a per-view basis by setting `"cache.autocollectDependencies": "false"` in the view's properties, or on a per-call basis with the new `autocollectDependency: false` option in `buildNodeUrl`.
+
+When a view links to more than 100 contents, a change in a folder that holds many of them also refreshes the view.
diff --git a/jahia-test-module/src/react/server/views/testAutocollectedCacheDependency/TestAutocollectedCacheDependency.tsx b/jahia-test-module/src/react/server/views/testAutocollectedCacheDependency/TestAutocollectedCacheDependency.tsx
index 634dbf28..825bc342 100644
--- a/jahia-test-module/src/react/server/views/testAutocollectedCacheDependency/TestAutocollectedCacheDependency.tsx
+++ b/jahia-test-module/src/react/server/views/testAutocollectedCacheDependency/TestAutocollectedCacheDependency.tsx
@@ -1,27 +1,27 @@
-import { buildNodeUrl, jahiaComponent } from "@jahia/javascript-modules-library";
+import { buildNodeUrl, jahiaComponent, Render } from "@jahia/javascript-modules-library";
import type { JCRNodeWrapper } from "org.jahia.services.content";
interface Props {
image: JCRNodeWrapper;
}
-const ImageAndRenderMarker = ({ image, testId }: { image: JCRNodeWrapper; testId: string }) => (
+const ImageAndRenderMarker = ({ src, testId }: { src: string; testId: string }) => (
-
+ {String(Date.now())}
);
/**
- * Neither view declares a cache dependency: building the URL is what registers one. The two differ
- * only in whether they let the engine collect it.
+ * No view declares a cache dependency: building the URL is what registers one. The views differ
+ * only in whether they let the engine collect it, and in how they call `buildNodeUrl`.
*/
jahiaComponent(
{
nodeType: "javascriptExample:testAutocollectedCacheDependency",
componentType: "view",
},
- ({ image }: Props) => ,
+ ({ image }: Props) => ,
);
jahiaComponent(
@@ -31,5 +31,87 @@ jahiaComponent(
componentType: "view",
properties: { "cache.autocollectDependencies": "false" },
},
- ({ image }: Props) => ,
+ ({ image }: Props) => ,
+);
+
+/** The context is passed explicitly, as a helper outside a component does. */
+jahiaComponent(
+ {
+ nodeType: "javascriptExample:testAutocollectedCacheDependency",
+ name: "explicitcontext",
+ componentType: "view",
+ },
+ ({ image }: Props, { renderContext, currentResource }) => (
+
+ ),
+);
+
+jahiaComponent(
+ {
+ nodeType: "javascriptExample:testAutocollectedCacheDependency",
+ name: "callopout",
+ componentType: "view",
+ },
+ ({ image }: Props) => (
+
+ ),
+);
+
+/**
+ * The cache stores an include inside the fragment of its parent. The included view builds the URL,
+ * so the parent fragment must depend on the image.
+ */
+jahiaComponent(
+ {
+ nodeType: "javascriptExample:testAutocollectedCacheDependency",
+ name: "includeparent",
+ componentType: "view",
+ },
+ () => ,
+);
+
+jahiaComponent(
+ {
+ nodeType: "javascriptExample:testAutocollectedCacheDependency",
+ name: "includedimage",
+ componentType: "view",
+ },
+ ({ image }: Props) => ,
+);
+
+/**
+ * A virtual node renders inside the fragment of its parent, which the cache stores with it. The
+ * view of the virtual node builds the URL, so the parent fragment must depend on the image.
+ */
+jahiaComponent(
+ {
+ nodeType: "javascriptExample:testAutocollectedCacheDependency",
+ name: "virtualparent",
+ componentType: "view",
+ },
+ ({ image }: Props) => (
+
+ ),
+);
+
+jahiaComponent(
+ {
+ nodeType: "javascriptExample:testAutocollectedCacheDependency",
+ name: "virtualimage",
+ componentType: "view",
+ },
+ ({ image }: Props) => ,
);
diff --git a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/js/server/CollectedCacheDependencies.java b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/js/server/CollectedCacheDependencies.java
index 94bc77a8..53ea90a2 100644
--- a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/js/server/CollectedCacheDependencies.java
+++ b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/js/server/CollectedCacheDependencies.java
@@ -1,19 +1,33 @@
package org.jahia.modules.javascript.modules.engine.js.server;
+import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
+import javax.servlet.ServletRequest;
+
import org.apache.commons.lang3.StringUtils;
import org.jahia.services.render.Resource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
- * Registers the cache dependencies a view autocollected while it
- * rendered, so that its fragment is flushed when a node it linked to or
- * displayed changes.
+ * Collects the cache dependencies a view resolves while it renders, and
+ * registers them on its resource, so that its fragment is flushed when a node
+ * it linked to or displayed changes.
+ *
+ * {@link CollectedCacheDependenciesFilter} puts a {@link Collector} for each
+ * cached fragment in a request attribute, with
+ * {@code RenderChain.pushAttribute}. A nested fragment puts its own, and the
+ * render chain restores the one of the parent when the nested render ends.
*/
public final class CollectedCacheDependencies {
@@ -25,10 +39,61 @@ public final class CollectedCacheDependencies {
*/
public static final int DEFAULT_LIMIT = 100;
+ /** The request attribute that holds the {@link Collector} of the rendered fragment. */
+ static final String ATTRIBUTE = CollectedCacheDependencies.class.getName();
+
+ /**
+ * The depth a merge never goes above: two segments, such as
+ * {@code /sites/mysite}.
+ */
+ static final int MIN_MERGE_DEPTH = 2;
+
private CollectedCacheDependencies() {
// static helper
}
+ /** The paths collected for one cached fragment. */
+ static final class Collector {
+ final Resource resource;
+ /** False when the view sets the opt-out property. */
+ final boolean enabled;
+ final Set paths = new LinkedHashSet<>();
+
+ Collector(Resource resource, boolean enabled) {
+ this.resource = resource;
+ this.enabled = enabled;
+ }
+ }
+
+ /**
+ * Collects a path for the fragment being rendered.
+ *
+ * @param request the current request
+ * @param path the canonical path of the node the view depends on
+ */
+ static void collect(ServletRequest request, String path) {
+ Object collector = request.getAttribute(ATTRIBUTE);
+ if (collector instanceof Collector && ((Collector) collector).enabled && StringUtils.isNotEmpty(path)) {
+ ((Collector) collector).paths.add(path);
+ }
+ }
+
+ /**
+ * Returns the paths collected for a resource.
+ *
+ * @param request the current request
+ * @param resource the rendered resource
+ * @return the collected paths, empty when none
+ */
+ static Set collected(ServletRequest request, Resource resource) {
+ Object collector = request.getAttribute(ATTRIBUTE);
+ // Resource overrides equals(): two renders of one node are two fragments
+ if (collector instanceof Collector && ((Collector) collector).resource == resource) {
+ return ((Collector) collector).paths;
+ }
+ return Set.of();
+ }
+
/**
* Reduces the collected paths and registers them on the resource, before the
* render chain stores its fragment.
@@ -42,11 +107,17 @@ public static void register(Resource resource, Collection paths, int lim
resource.getDependencies().addAll(reduced.paths);
resource.getRegexpDependencies().addAll(reduced.regexps);
- if (!reduced.regexps.isEmpty()) {
- logger.info(
- "{} autocollected {} node dependencies, more than the {} limit, so they were merged into {}. "
- + "If incorrect, narrow what the view lists, or set cache.autocollectDependencies=false on it and declare the dependency it needs.",
- resource, paths.size(), limit, reduced.regexps);
+ int entries = reduced.paths.size() + reduced.regexps.size();
+ if (entries > limit) {
+ logger.warn("{} autocollected {} node dependencies, and {} remain after the merge, more than the {} limit. "
+ + "No merge goes above /sites/. Narrow what the view links to, or set "
+ + "cache.autocollectDependencies=false on it and declare the dependency it needs.",
+ resource, reduced.collected, entries, limit);
+ } else if (!reduced.regexps.isEmpty()) {
+ logger.info("{} autocollected {} node dependencies, more than the {} limit, so some were merged into {}. "
+ + "If incorrect, narrow what the view links to, or set "
+ + "cache.autocollectDependencies=false on it and declare the dependency it needs.",
+ resource, reduced.collected, limit, reduced.regexps);
}
}
@@ -57,10 +128,13 @@ public static void register(Resource resource, Collection paths) {
/** The dependencies to register, after reduction. */
static final class Reduced {
+ /** The number of collected paths the reduction kept. */
+ final int collected;
final Collection paths;
final Collection regexps;
- private Reduced(Collection paths, Collection regexps) {
+ private Reduced(int collected, Collection paths, Collection regexps) {
+ this.collected = collected;
this.paths = paths;
this.regexps = regexps;
}
@@ -68,51 +142,105 @@ private Reduced(Collection paths, Collection regexps) {
/**
* Reduces a fragment's collected dependencies to at most {@code limit} cache
- * entries.
+ * entries, when a merge can reach that number.
+ *
+ * The paths under {@code /modules} are dropped, because the core cache
+ * filters store no dependency there. Above the limit, the deepest folder that
+ * covers two entries or more becomes one regexp, the biggest first at one
+ * depth. The merge goes up one depth at a time until the entries fit. A merge
+ * never covers a folder above {@value #MIN_MERGE_DEPTH} segments, such as
+ * {@code /sites}, so the result can stay above the limit.
*
* @param dependencies the paths the fragment resolved
* @param limit the number of entries a fragment may write
* @return the paths and regexps to register
*/
static Reduced reduce(Collection dependencies, int limit) {
- if (dependencies.size() <= limit) {
- return new Reduced(dependencies, Set.of());
+ Set kept = new LinkedHashSet<>();
+ for (String path : dependencies) {
+ if (!isUnderModules(path)) {
+ kept.add(path);
+ }
+ }
+ int collected = kept.size();
+ if (collected <= limit) {
+ return new Reduced(collected, kept, Set.of());
}
- return new Reduced(Set.of(), Set.of(commonRootRegex(dependencies)));
- }
-
- /**
- * A regexp matching the deepest path every dependency lives under and
- * everything
- * below it, or everything under the repository root when they share no first
- * segment.
- */
- private static String commonRootRegex(Collection paths) {
- String[] common = null;
- for (String path : paths) {
+ // the exact paths and the merged folders, each with its segments
+ Map exact = new LinkedHashMap<>();
+ Map folders = new LinkedHashMap<>();
+ int deepest = 0;
+ for (String path : kept) {
String[] segments = StringUtils.split(path, '/');
- if (common == null) {
- common = segments;
- continue;
- }
- int shared = 0;
- while (shared < common.length && shared < segments.length
- && common[shared].equals(segments[shared])) {
- shared++;
- }
- if (shared == 0) {
- return "/.*";
+ exact.put(path, segments);
+ deepest = Math.max(deepest, segments.length);
+ }
+
+ // Two folders at one depth never overlap, so a merge at one depth leaves the
+ // other folders of that depth as they were
+ for (int depth = deepest; depth >= MIN_MERGE_DEPTH && exact.size() + folders.size() > limit; depth--) {
+ Map atDepth = new HashMap<>();
+ group(exact, depth, atDepth, true);
+ group(folders, depth, atDepth, false);
+
+ List candidates = new ArrayList<>();
+ for (Folder folder : atDepth.values()) {
+ if (folder.size() >= 2) {
+ candidates.add(folder);
+ }
}
- if (shared < common.length) {
- common = Arrays.copyOf(common, shared);
+ candidates.sort(Comparator.comparingInt(Folder::size).reversed().thenComparing(folder -> folder.key));
+
+ for (Folder folder : candidates) {
+ if (exact.size() + folders.size() <= limit) {
+ break;
+ }
+ folder.exact.forEach(exact::remove);
+ folder.folders.forEach(folders::remove);
+ folders.put(folder.key, folder.segments);
}
}
- if (common == null || common.length == 0) {
- return "/.*";
+ Set regexps = new LinkedHashSet<>();
+ for (String folder : folders.keySet()) {
+ regexps.add("/" + Pattern.quote(folder) + "(/.*)?");
+ }
+ return new Reduced(collected, new LinkedHashSet<>(exact.keySet()), regexps);
+ }
+
+ private static boolean isUnderModules(String path) {
+ return "/modules".equals(path) || path.startsWith("/modules/");
+ }
+
+ /** The entries at or below one folder. An entry covers its own path, because a regexp on a folder matches it. */
+ private static final class Folder {
+ final String key;
+ final String[] segments;
+ final List exact = new ArrayList<>();
+ final List folders = new ArrayList<>();
+
+ Folder(String key, String[] segments) {
+ this.key = key;
+ this.segments = segments;
}
- return "/" + Pattern.quote(String.join("/", common)) + "(/.*)?";
+ int size() {
+ return exact.size() + folders.size();
+ }
+ }
+
+ /** Groups the entries deep enough by their folder at one depth. */
+ private static void group(Map entries, int depth, Map atDepth, boolean exact) {
+ for (Map.Entry entry : entries.entrySet()) {
+ String[] segments = entry.getValue();
+ if (segments.length < depth) {
+ continue;
+ }
+ String[] folderSegments = Arrays.copyOf(segments, depth);
+ Folder folder = atDepth.computeIfAbsent(String.join("/", folderSegments),
+ key -> new Folder(key, folderSegments));
+ (exact ? folder.exact : folder.folders).add(entry.getKey());
+ }
}
}
diff --git a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/js/server/CollectedCacheDependenciesFilter.java b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/js/server/CollectedCacheDependenciesFilter.java
new file mode 100644
index 00000000..a78d9d0f
--- /dev/null
+++ b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/js/server/CollectedCacheDependenciesFilter.java
@@ -0,0 +1,94 @@
+package org.jahia.modules.javascript.modules.engine.js.server;
+
+import java.util.Properties;
+import java.util.Set;
+
+import javax.jcr.RepositoryException;
+
+import org.jahia.services.render.RenderContext;
+import org.jahia.services.render.Resource;
+import org.jahia.services.render.View;
+import org.jahia.services.render.filter.AbstractFilter;
+import org.jahia.services.render.filter.RenderChain;
+import org.jahia.services.render.filter.RenderFilter;
+import org.jahia.services.render.scripting.Script;
+import org.osgi.service.component.annotations.Component;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Gives each resource that the cache stores as a fragment a collector for the
+ * cache dependencies its view resolves, and registers what it collected.
+ *
+ * {@code AggregateCacheFilter} (16) keeps the key of the fragment it stores in
+ * the module param {@code cacheKey}, from its {@code prepare} to its
+ * {@code execute}. This filter runs only on a resource that holds that param,
+ * so it follows the decision of the cache on every core version. A resource the
+ * cache does not store, such as an include, a wrapper, an option or a fragment
+ * cached with its parent, gets no collector of its own. Its view then collects
+ * into the collector of the enclosing fragment, which holds its markup. The
+ * opt-out of the enclosing view then decides for both views, and the opt-out of
+ * the inner view has no effect.
+ *
+ * The deprecated cache implementation, {@code AggregateFilter} with
+ * {@code CacheFilter}, sets no such param, so this filter collects nothing
+ * with it.
+ *
+ * The priority puts {@link #prepare} after the cache filter (16) sets the
+ * param. It puts {@link #execute} after the script and the wrappers, and before
+ * the cache filter removes the param and stores the dependencies of the
+ * fragment. A render
+ * filter whose priority is below this one runs its own {@code execute} after
+ * this filter, so a path it collects there is not registered.
+ */
+@Component(service = RenderFilter.class, immediate = true)
+public class CollectedCacheDependenciesFilter extends AbstractFilter {
+
+ private static final Logger logger = LoggerFactory.getLogger(CollectedCacheDependenciesFilter.class);
+
+ /** The view property that turns autocollection off for a view. */
+ static final String DISABLE_PROPERTY = "cache.autocollectDependencies";
+
+ static final float PRIORITY = 17f;
+
+ /** The module param of the fragment key that {@code AggregateCacheFilter} stores. */
+ static final String CACHE_KEY = "cacheKey";
+
+ public CollectedCacheDependenciesFilter() {
+ setPriority(PRIORITY);
+ setDescription("Registers the cache dependencies a JavaScript view autocollected while it rendered.");
+ addCondition((renderContext, resource) -> resource.getModuleParams().get(CACHE_KEY) != null);
+ }
+
+ @Override
+ public String prepare(RenderContext renderContext, Resource resource, RenderChain chain) throws Exception {
+ chain.pushAttribute(renderContext.getRequest(), CollectedCacheDependencies.ATTRIBUTE,
+ new CollectedCacheDependencies.Collector(resource, isEnabled(renderContext, resource)));
+ return null;
+ }
+
+ @Override
+ public String execute(String previousOut, RenderContext renderContext, Resource resource, RenderChain chain)
+ throws Exception {
+ Set paths = CollectedCacheDependencies.collected(renderContext.getRequest(), resource);
+ CollectedCacheDependencies.register(resource, paths);
+ return previousOut;
+ }
+
+ private boolean isEnabled(RenderContext renderContext, Resource resource) {
+ try {
+ Properties properties = viewProperties(renderContext, resource);
+ return properties == null || !"false".equals(properties.getProperty(DISABLE_PROPERTY));
+ } catch (RepositoryException e) {
+ logger.debug("Cannot resolve the view of {}, its dependencies are collected", resource, e);
+ return true;
+ }
+ }
+
+ /** The properties of the view that renders the resource, or null when it has none. */
+ Properties viewProperties(RenderContext renderContext, Resource resource) throws RepositoryException {
+ Script script = resource.getScript(renderContext);
+ View view = script == null ? null : script.getView();
+ return view == null ? null : view.getProperties();
+ }
+}
diff --git a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/js/server/RenderHelper.java b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/js/server/RenderHelper.java
index db0b42be..89de4ac9 100644
--- a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/js/server/RenderHelper.java
+++ b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/js/server/RenderHelper.java
@@ -44,6 +44,7 @@
import javax.annotation.Nullable;
import javax.inject.Inject;
import javax.jcr.RepositoryException;
+import javax.servlet.http.HttpServletRequest;
import javax.servlet.jsp.JspException;
import javax.servlet.jsp.tagext.BodyTagSupport;
import javax.servlet.jsp.tagext.TagSupport;
@@ -343,6 +344,20 @@ public void addCacheDependency(Map attr, RenderContext renderCon
renderTag(new AddCacheDependencyTag(), attr, renderContext);
}
+ /**
+ * Collect a cache dependency for the fragment being rendered. Unlike
+ * {@link #addCacheDependency}, the dependency is registered once the view has
+ * rendered. A view that sets {@code cache.autocollectDependencies} to
+ * {@code false} collects nothing. Above 100 paths, the paths are merged into
+ * regular expressions.
+ *
+ * @param path the canonical path of the node to add as a dependency
+ * @param request the current request, which holds the collector of the fragment
+ */
+ public void autocollectCacheDependency(String path, HttpServletRequest request) {
+ CollectedCacheDependencies.collect(request, path);
+ }
+
/**
* Calculate the relative path from one node to another node.
*
diff --git a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/views/JSScript.java b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/views/JSScript.java
index ef1f7566..d0306735 100644
--- a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/views/JSScript.java
+++ b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/views/JSScript.java
@@ -18,10 +18,6 @@
import org.graalvm.polyglot.Value;
import org.graalvm.polyglot.proxy.ProxyObject;
import org.jahia.modules.javascript.modules.engine.jsengine.GraalVMEngine;
-import java.util.ArrayList;
-import java.util.List;
-
-import org.jahia.modules.javascript.modules.engine.js.server.CollectedCacheDependencies;
import org.jahia.services.render.RenderContext;
import org.jahia.services.render.RenderException;
import org.jahia.services.render.Resource;
@@ -65,7 +61,7 @@ public String execute(Resource resource, RenderContext renderContext) throws Ren
viewValues.put("bundle", Value.asValue(jsView.getModule().getBundle()));
Object executionResult = Value.asValue(viewRenderer.get("render")).execute(resource, renderContext, ProxyObject.fromMap(viewValues));
- return readRenderResult(Value.asValue(executionResult), resource);
+ return Value.asValue(executionResult).asString();
}));
if (jsView.isTemplate()) {
@@ -81,26 +77,6 @@ public String execute(Resource resource, RenderContext renderContext) throws Ren
return output;
}
- /**
- * Reads what a view renderer returned: the markup, and the cache dependencies
- * the view autocollected while it rendered.
- *
- * @param result what the renderer returned
- * @param resource the resource that was rendered
- * @return the markup
- */
- private String readRenderResult(Value result, Resource resource) {
- Value dependencies = result.getMember("autocollectedDependencies");
- int length = (int) dependencies.getArraySize();
- List collected = new ArrayList<>(length);
- for (int i = 0; i < length; i++) {
- collected.add(dependencies.getArrayElement(i).asString());
- }
- CollectedCacheDependencies.register(resource, collected);
-
- return result.getMember("html").asString();
- }
-
@Override
public View getView() {
return jsView;
diff --git a/javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/js/server/CollectedCacheDependenciesFilterTest.java b/javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/js/server/CollectedCacheDependenciesFilterTest.java
new file mode 100644
index 00000000..e78669a1
--- /dev/null
+++ b/javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/js/server/CollectedCacheDependenciesFilterTest.java
@@ -0,0 +1,207 @@
+package org.jahia.modules.javascript.modules.engine.js.server;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+
+import java.lang.reflect.Proxy;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.IdentityHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import javax.servlet.http.HttpServletRequest;
+
+import org.jahia.services.render.RenderContext;
+import org.jahia.services.render.Resource;
+import org.jahia.services.render.filter.AbstractFilter;
+import org.jahia.services.render.filter.RenderChain;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Runs {@link CollectedCacheDependenciesFilter} in a real {@link RenderChain},
+ * between a cache filter and a view that stand in for those of Jahia.
+ */
+public class CollectedCacheDependenciesFilterTest {
+
+ private RenderContext renderContext;
+ // keyed by identity: Resource overrides equals, and two renders of one node are two fragments
+ /** The resources the cache stores, and the dependencies each one had when the cache stored it. */
+ private final Set cached = Collections.newSetFromMap(new IdentityHashMap<>());
+ private final Map> stored = new IdentityHashMap<>();
+ /** What each view does while it renders. */
+ private final Map views = new IdentityHashMap<>();
+ private final Set optedOut = Collections.newSetFromMap(new IdentityHashMap<>());
+
+ /** Stands in for AggregateCacheFilter: it sets the cache key of a fragment it stores, and removes it in execute. */
+ private final AbstractFilter cache = new AbstractFilter() {
+ {
+ setPriority(16);
+ }
+
+ @Override
+ public String prepare(RenderContext renderContext, Resource resource, RenderChain chain) {
+ if (cached.contains(resource)) {
+ resource.getModuleParams().put(CollectedCacheDependenciesFilter.CACHE_KEY, "key");
+ }
+ return null;
+ }
+
+ @Override
+ public String execute(String previousOut, RenderContext renderContext, Resource resource, RenderChain chain) {
+ if (resource.getModuleParams().remove(CollectedCacheDependenciesFilter.CACHE_KEY) != null) {
+ Set dependencies = new HashSet<>(resource.getDependencies());
+ dependencies.addAll(resource.getRegexpDependencies());
+ dependencies.remove(resource.getNodePath());
+ stored.put(resource, dependencies);
+ }
+ return previousOut;
+ }
+ };
+
+ private final CollectedCacheDependenciesFilter filter = new CollectedCacheDependenciesFilter() {
+ @Override
+ Properties viewProperties(RenderContext renderContext, Resource resource) {
+ Properties properties = new Properties();
+ if (optedOut.contains(resource)) {
+ properties.setProperty(DISABLE_PROPERTY, "false");
+ }
+ return properties;
+ }
+ };
+
+ /** Stands in for the script: it runs the view of the resource. */
+ private final AbstractFilter script = new AbstractFilter() {
+ {
+ setPriority(100);
+ }
+
+ @Override
+ public String prepare(RenderContext renderContext, Resource resource, RenderChain chain) {
+ views.getOrDefault(resource, () -> { }).run();
+ return "";
+ }
+ };
+
+ /** A request that only keeps its attributes. */
+ private static HttpServletRequest request() {
+ Map