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.
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.
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.
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.
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?
/, 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?
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?
4. The body says the change dropped the dead utility-library webjar dependencies. What does the build file say?
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?
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.