Deleting an attribute moved the decision

uPortal pull request 2983 reads as find-and-replace across 17 files. Three of its lines delete an attribute, and deleting it changes who works out where a file lives: the server, or the browser.

uPortal-Project/uPortal PR 2983 at 0468c6d, explained 2026-09-01

Background

New to uPortal skins, or to servlet contexts? Expand for the deeper background.

uPortal renders a page by wrapping portlets in a theme, and the theme's appearance comes from a skin. A skin is a directory plus a descriptor: an XML file listing the stylesheets and scripts the page needs.

<js included="plain">/resource-server/rs/underscore/1.8.3/underscore.js</js>
<css included="aggregated">/resource-server/webjars/.../dataTables.bootstrap5.min.css</css>

The included attribute says which build the entry belongs to. Aggregation is uPortal concatenating and minifying a skin's own files into one bundle, so aggregated entries are the versions used when that bundle is built.

Where those files come from is the subject here. A Java servlet container runs several web applications side by side, each mounted at a context path, the first segment of the URL. uPortal is one context. A second one, the resource server, carries shared front-end libraries, so several portals on a server can reference the same copies. Its older context path was /ResourceServingWebapp/ and the current one is /resource-server/.

Three names recur below, so they are here rather than in the collapsed part. A webjar is a front-end library packaged as a Java artifact, so a build resolves it like any other dependency. Aggregation is uPortal concatenating and minifying a skin's own files into one bundle. And rs is a JSP tag library that the resource server ships, bound once in this codebase, used for building URLs and for compressing inline scripts.

An entry in a skin descriptor could carry one extra attribute, and this change is about what it did.

uPortal-webapp/.../media/skins/respondr/common/common_skin.xml, before

    <js included="plain" resource="true">/rs/underscore/1.8.3/underscore.js</js>

Note the path: no context, just /rs/.... With resource="true", a component named below supplied the missing prefix while the page rendered. Without the attribute, the path is emitted as written.

Who resolves it

The theme calls into Java for every skin entry.

uPortal-webapp/src/main/resources/layout/theme/resourcesTemplates.xsl:30

<xsl:template name="skinResources">
    <xsl:param name="path" />
    <xsl:variable name="resourceHelper" select="resources:getElmenentsProvider($RESOURCES_ELEMENTS_HELPER)" />
    <xsl:variable name="request" select="resources:getHttpServletRequest($CURRENT_REQUEST)" />
    <xsl:copy-of select="resources:getResourcesXmlFragment($resourceHelper, $request, $path)" />

That reaches a thin delegate in this repository, at uPortal-web/.../web/skin/ResourcesElementsXsltcHelper.java:58, which hands straight off to a provider bean built at RenderingPipelineConfiguration.java:325 with no context path or base URL configured on it.

And then the trail leaves the repository. The class that reads the attribute, ResourcesElementsProviderImpl, ships in org.jasig.resourceserver:resource-server-utils, pinned at gradle.properties:98 to resourceServerVersion=1.5.3. No copy of that jar is in the checkout, so the exact prefix logic cannot be read here.

Key concept resource="true" is an indirection. The descriptor says what it wants and lets a component decide where that lives, per request, at render time. An explicit path says where it lives and leaves nothing to decide. Both produce a URL; they differ in who computes it and when.

Intuition

The change deletes the attribute in three places and writes the context into the path instead.

-    <js included="plain" resource="true">/rs/underscore/1.8.3/underscore.js</js>
+    <js included="plain">/resource-server/rs/underscore/1.8.3/underscore.js</js>

One attribute out, one path segment in. It looks like the same URL spelled differently. The pull request body says the two forms reach byte-identical paths in the deployment the author tested, checked with curl. Section 7 comes back to what this repository can say about that, which is nothing.

What actually changes is who does the work, and what happens when the answer is not there.

flowchart LR
    subgraph portal["the uPortal context"]
        direction TB
        W["the uPortal war, built by unpacking<br/>the rs/ tree and every webjar into it"]
        A["/scripts/portal-analytics.js<br/>a file only uPortal has"]
    end

    subgraph rserver["the /resource-server/ context, deployed separately"]
        direction TB
        R["the same rs/ tree and webjars,<br/>served by another project"]
    end

    OLD["with the attribute: /rs/underscore/1.8.3/underscore.js"]
    NEW["without it: /resource-server/rs/underscore/1.8.3/underscore.js"]

    OLD -->|the provider supplies a context at render time| W
    NEW -->|the browser requests exactly this path| R
    OLD -.->|four jsTree entries and the analytics script still use this form| A

The second half of that picture is written down in this repository, even though the provider is not. A fixture pair under src/test/resources records what the no-attribute form produces. No test in the checkout loads those files, so they document the behavior rather than asserting it.

uPortal-webapp/src/test/resources/.../web/skin/resources1.xml:30

<!-- absolute won't get touched, prove the 2 don't aggregate -->

The expected output confirms it: a path starting with / appears in the emitted markup unchanged, and it is excluded from aggregation. A relative path, by contrast, is resolved against the skin directory and can be aggregated.

resource="true", path /rs/... no attribute, path /resource-server/rs/...
Who computes the URL the provider, on the server, per request nobody; it is emitted as written
Which deployment the URL points into whichever one the provider picks at render time the one named in the path
What has to be true for it to resolve the serving context has the file a context is deployed at /resource-server/ and has the file
Aggregation excluded excluded

Row two is the one the diff does not show, and it is the one to be careful about. uPortal's own build unpacks every webjar and the resource server's rs/ tree into its war, at uPortal-webapp/build.gradle:132 and :139, and the war task puts both at the root. So copies of these libraries were already inside uPortal.

That the old form resolved to those copies is my reading, not something this repository states. The code that chose the prefix is in the absent jar, and whether the external artifact happens to contain these three specific paths cannot be checked here either. The new form needs no reading: it names one context, literally.

Edge case The attribute cannot simply be replaced everywhere. Five active resource="true" entries survive this change, and one of them is common_skin.xml:110, pointing at /scripts/portal-analytics.js. That file is uPortal's own, at uPortal-webapp/src/main/webapp/scripts/portal-analytics.js, not a resource-server asset. Rewriting that one to /resource-server/... would name a file that is not there.

Code walkthrough

The flow, end to end. The theme asks for a skin's entries. A provider turns each into markup. The browser requests what it is given. This change edits the descriptors and JSPs that feed the first step, and in three places it changes which participant decides the URL.

1. Six kinds of change in 17 files

$ grep '^diff --git' diff.txt | wc -l
17

Six kinds of change, counted by asking which files have a removed line matching each pattern:

$ awk '/^diff --git/{f=$3} /^-/ && /ResourceServingWebapp/{print f}' diff.txt | sort -u | wc -l
8
$ awk '/^diff --git/{f=$3} /^-/ && /resource=\"true\"/{print f}' diff.txt | sort -u | wc -l
1
$ awk '/^diff --git/{f=$3} /^-/ && /compressJs/{print f}' diff.txt | sort -u | wc -l
6
$ awk '/^diff --git/{f=$3} /^-/ && /rs:resourceURL/{print f}' diff.txt | sort -u | wc -l
3

These groups overlap, so they do not sum to 17. One file can be in several: reportGraph.jsp loses both compressJs wrappers and resourceURL tags, and common_skin.xml both loses library entries and has entries rewritten. The union of all six groups is the 17 files.

Kind Files
/ResourceServingWebapp/ to /resource-server/ in a literal path 8
resource="true" removed, path made explicit 1, three entries
Library entries deleted, replaced by dated comments 2
<rs:compressJs> wrappers removed 6
<rs:resourceURL> tags removed or replaced 3
Inline JavaScript rewritten to drop a library 2

Groups two and five are the same move in two languages. In the skin XML the attribute goes and the path becomes literal; in a JSP the tag goes and the path becomes literal.

uPortal-webapp/.../WEB-INF/jsp/Statistics/reportGraph.jsp:26

-<rs:resourceURL var="rgbcolorScriptPath" value="/rs/canvg/r144/rgbcolor-r144.min.js"/>
+<c:set var="rgbcolorScriptPath" value="/resource-server/rs/canvg/r144/rgbcolor-r144.min.js"/>

A tag that computed a URL becomes a variable assignment holding one. Four of them change in that file:

$ grep -c 'rs:resourceURL' uPortal-webapp/src/main/webapp/WEB-INF/jsp/Statistics/reportGraph.jsp
0

Zero remain after the change, and the diff shows four removed.

2. A tag library with no remaining callers

Between groups four and five, the rs prefix stops being used in any JSP or XSL file.

$ grep -rIn '<rs:' --include='*.jsp' --include='*.xsl' .
$ 

No hits. The binding that makes the prefix available is still there, at uPortal-webapp/.../WEB-INF/jsp/include.jsp:30:

<%@ taglib prefix="rs" uri="http://www.jasig.org/resource-server" %>

The body describes the removed <rs:compressJs> wrappers as already a no-op upstream, with minification moved to esbuild. That is a statement about the tag's implementation, which ships in the same external jar as the provider. The checkout has two tag library descriptors of its own, under WEB-INF/tag/, and neither is the resource server's; no copy of its descriptor or its tag handler is here. So the claim can be neither confirmed nor refuted from this repository.

3. Losing lodash, by hand

Two JSPs loaded lodash 4.17.4 with a script tag and used a handful of its helpers in inline JavaScript. The change deletes the script tag and writes replacements for exactly the functions each page called.

uPortal-webapp/.../WEB-INF/jsp/Invoker/sitemap.jsp:73

    // hasPath('a.b.c', obj) — supports the dot-path semantics of lodash _.has
    function hasPath(obj, dotPath) {
        var parts = dotPath.split('.');
        for (var i = 0; i < parts.length; i++) {
            if (obj == null || typeof obj !== 'object' || !(parts[i] in obj)) {
                return false;
            }
            obj = obj[parts[i]];
        }
        return true;
    }

The rest is substitution: _.forEach(collection, fn) becomes collection.forEach(fn), _.forOwn and one _.forEach over an object become Object.keys(...).forEach(...), and _.unescape, _.startCase, and _.kebabCase become local functions. The two files grow because the shims and the Object.keys boilerplate are longer than the calls they replace.

$ grep -rIn 'lodash' . | grep -v '^./.git/' | grep -v package-lock
./uPortal-webapp/src/main/webapp/WEB-INF/jsp/Invoker/sitemap.jsp:73
./uPortal-webapp/src/main/webapp/WEB-INF/jsp/Invoker/sitemap.jsp:84

Both remaining hits are the explanatory comments. Nothing else in the repository references it.

4. Deleting a library, and showing the work

One deletion carries its own justification in the file.

uPortal-webapp/.../media/skins/respondr/common/common_skin.xml:26

    <!-- modernizr removed 2026-05-06: zero Modernizr.* callsites in workspace,
         no .no-js/.js class dependencies. Pure dead weight. -->

The comment states its own justification, which makes it checkable.

$ grep -rIn -i 'modernizr' . | grep -v '^./.git/'
uPortal-webapp/.../respondr/common/common_skin.xml:26:    <!-- modernizr removed ...
docs/architecture/subsystem-analysis.md:261:| **Modernizr** | 2.6.2 | Outdated | ...
docs/architecture/subsystem-analysis.md:327:- Modernizr (unnecessary for modern ...
$ grep -rIn 'no-js' . | grep -v '^./.git/'
uPortal-webapp/.../respondr/common/common_skin.xml:27:         no .no-js/.js class ...

Printing the paths rather than counting them is the point here. The three Modernizr hits are the comment's first line and two lines in an architecture document that already listed the library as outdated. The only no-js hit is the comment's second line. So the claim holds: nothing called it, and nothing depended on the classes it set.

Four polyfill entries also go from the same file, for core-js-bundle, regenerator-runtime, whatwg-fetch, and the web components bundle, along with a normalize.css pair from defaultSkin/skin.xml.

Where the body and the build disagree The body's first commit description says it dropped "the dead utility-lib webjar dependencies". The skin entries are gone, and the dependency declarations are not: uPortal-webapp/build.gradle:97 and :99 through :101 still declare all four webjars, with versions at gradle.properties:118, :119, :121, and :122. The build still resolves them and still unpacks them into the war. What changed is that the skin no longer tells a browser to load them.

5. Two data files, for different reasons

A path change reached two files that are not code.

The first is seed data, at .../tenants/sampledata/portlet-definition/skin.portlet-definition.xml:45. It is imported when a tenant is created, through a listener configured at portal.properties:751. So a stale path here would be written into a live database at provisioning time, where no search of the source tree would find it afterwards.

The second, at uPortal-webapp/src/test/resources/.../portlet/test_4-0.portlet.xml:48, is an import fixture in the 4.0 document format. No test in the checkout names it. The edit keeps its paths consistent with the rest of the change.

6. How much of the old context is left

$ grep -rIn 'ResourceServingWebapp' . | grep -v '^./.git/' | wc -l
7
$ grep -rIn 'resource="true"' . | grep -v '^./.git/' | wc -l
7

Of the seven old-context references, two sit inside a commented-out block in common_skin.xml, headed "Uncomment for internationalization." Four are in the fixture pair from Intuition, where the literal string is the input and the expected output. One is in an architecture document.

Of the seven resource="true" occurrences, two are in that same commented-out block and five are live: four jsTree entries at common_skin.xml:99 through :102, and the analytics script from the edge case above.

7. What the checkout cannot settle

Several of the body's claims cannot be checked from this checkout. Here is which, and why.

Two of the body's claims check out here. The prefix swaps, the deletions, and the tag removals are all in the diff as described, file by file. And the Modernizr rationale holds, by the search above.

Three cannot be checked from this repository, for the same reason each time: the thing they describe is not in it. Whether <rs:compressJs> is a no-op depends on the external jar. Whether the two forms produce byte-identical URLs depends on the external deployment. And the body describes two commits, while this clone has one, with the second commit's edits already folded in.

The last of those is worth following. Nothing in this repository serves /resource-server/. web.xml declares one static-asset servlet, Spring's, mapped at /resources/*, and nothing maps /resource-server/. Elsewhere a Spring handler does serve /webjars/** from the classpath, which is a different path under a different dispatcher. That context is deployed by a separate project, which pins its own resource-server version. So whether these paths resolve is a fact about a deployment rather than about this code.

Two statements in the tree sit against each other at this commit. The comment replacing the polyfill entries gives modern browser support as the reason they are unnecessary. docs/SUPPORTED_BROWSERS.md:10 lists Internet Explorer among supported browsers. Reading the code alone would not tell you which one describes the project's intent.

The transferable part When an indirection disappears, look for what it was absorbing. An attribute that resolves a path at render time can also be papering over a difference between environments, and replacing it with a literal makes the environment part of the source. An explicit path is easier to read and to grep. The trade is that the deployment now has to match what the file says, and a file cannot check that.

Quiz

Five questions about why the change is shaped the way it is. Click an option to see the answer.

1. Removing resource="true" and writing /resource-server/ into the path changes what, at runtime?

Both forms start with /, so aggregation is excluded either way, and the fixture in this repository records that. The body reports identical URLs in a correctly deployed environment. Identical output still does not mean the same mechanism produced it.

2. Five resource="true" entries survive this change. Why can they not all be rewritten the same way?

That is what makes the attribute an indirection rather than a prefix: it resolved against whatever was serving, which for that entry is the portal itself. Two of the seven occurrences are indeed in a commented-out block, but the other five are live. Four of those are the jsTree entries, two js and two css.

3. The Modernizr deletion carries a comment claiming zero call sites and no class dependencies. What did checking the claim against the repository find?

A comment that states its own justification is checkable, and this one checks out. Where the library was served from does not affect whether anything called it, since the call sites would be in this repository's own scripts.

4. The body says the change dropped the dead utility-library webjar dependencies. What does the build file say?

The difference between "no longer loaded by a browser" and "no longer a dependency" is the whole distance here. The files are still fetched at build time and still packaged; nothing in the skin now points a page at them.

5. After this change, the rs tag prefix has no remaining uses in any JSP or XSL, and the taglib binding that declares it is still in include.jsp. What does keeping the binding do?

A taglib directive is resolved at translation time whether or not any tag is used, and it is not stripped. It also has nothing to do with the paths: those are now plain strings in the file. Whether an unused declaration should stay is a question for this project.