Skip to content

Commit 1c6a2f7

Browse files
shai-almogclaude
andcommitted
Fix CSSWatcher live reload: drop stale bindings + extract m2 designer jar
Two recent CSS/localization changes regressed the simulator's live CSS reload, in different ways. 1. addThemeProps stomped user edits with stale @cn1-bind entries. PR #4884 added applyThemeBindings() inside UIManager.buildTheme so a single addThemeProps({"@accent-color": ...}) override could retune every var()-bound theme key. But CSSWatcher reloads the theme through the same code path -- and addThemeProps never clears themeConstants. When the user replaced a `var()` rule with a literal in their CSS, the recompiled theme.res no longer emitted the matching `@cn1-bind:<key>` entry, but the previous binding was still sitting in themeConstants. applyThemeBindings happily re-overlaid the user's fresh literal value with the stale binding's resolved value, so the visible change disappeared on every reload. Fix: in buildTheme, before iterating the incoming Hashtable, detect any binding whose subject style key the new load is re-setting without re-asserting the binding alongside, and drop those bindings before the overlay pass runs. Pure `@accent-color` overrides keep working because they don't carry style keys, so no bindings are considered stale. 2. MavenUtils.findDesignerJarInM2 returned the unrunnable wrapper zip. PR #4852 added an m2 fallback for the CSSWatcher's designer-jar lookup, used whenever -Dcodename1.designer.jar isn't passed in (e.g. simulator launched from the IDE rather than `mvn cn1:run`). The helper returned `codenameone-designer-<v>-jar-with-dependencies.jar` directly from m2 -- but that artifact is a zip wrapper containing a single inner designer_1.jar (see maven/designer/pom.xml's antrun step), with no top-level Main-Class manifest. `java -jar wrapper.zip` fails with "no main manifest attribute", the CSS subprocess never starts, and the watcher silently waits for ::refresh:: lines that never come. Fix: mirror AbstractCN1Mojo.getDesignerJar's pattern -- unzip the wrapper to an `<artifact>.jar-extracted/` sibling on demand and return the inner designer_1.jar so `java -jar` actually launches. Tests: - UIManagerThemeBindingsTest gains three regression cases: cssReloadDropsStaleBindingWhenRuleBecomesLiteral (the actual reproducer), cssReloadKeepsBindingWhenStillEmittedTogether (guard against an over-eager fix), and overrideOnlyReloadKeepsBindings (repeated `@accent-color` retunes still work). The first fails before the UIManager fix; all three pass after. - MavenUtilsTest is new and covers the wrapper-vs-inner-jar resolution with five cases: happy path, re-use of extracted inner jar when the wrapper hasn't changed, re-extract when the wrapper mtime advances, null when the core jar isn't in an m2 layout, and null when the designer artifact is missing. To make these actually executable, the javase pom now pins maven-surefire-plugin to 3.2.5 (the parent's 2.21.0 doesn't auto-discover JUnit Jupiter). The pre-existing CSSWatcherTest + LocationSimulationTest + JavaSEPortFontMappingTest in the same module also start running as a side effect. - pr.yml gets a new "Run JavaSE port unit tests" step so this whole test class -- which compiled but never executed -- is wired into CI. Without it, regressions in CSSWatcher/MavenUtils/JavaSEPort helpers would continue to slip through, which was the original gap the user flagged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6bc2988 commit 1c6a2f7

6 files changed

Lines changed: 364 additions & 4 deletions

File tree

.github/workflows/pr.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,19 @@ jobs:
113113
-Dcodename1.platform=javase \
114114
-Dcn1.binaries="${CN1_BINARIES}" \
115115
-pl codenameone-maven-plugin -am -Plocal-dev-javase test
116+
- name: Run JavaSE port unit tests
117+
# Surface regressions in the simulator-side helpers (CSSWatcher
118+
# localization, MavenUtils designer-jar resolution, JavaSEPort font
119+
# mapping). Without this step the tests in maven/javase compile but
120+
# never execute on PRs.
121+
working-directory: maven
122+
env:
123+
CN1_BINARIES: ${{ github.workspace }}/maven/target/cn1-binaries
124+
run: |
125+
mvn -B -Dmaven.javadoc.skip=true \
126+
-Dcodename1.platform=javase \
127+
-Dcn1.binaries="${CN1_BINARIES}" \
128+
-pl javase -Plocal-dev-javase test
116129
- name: Run SpotBugs for ports and Maven plugin
117130
if: ${{ matrix.java-version == 8 }}
118131
working-directory: maven

CodenameOne/src/com/codename1/ui/plaf/UIManager.java

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1790,6 +1790,31 @@ private void buildTheme(Hashtable themeProps) {
17901790
Display.getInstance().installNativeTheme();
17911791
accessible = a;
17921792
}
1793+
// CSSWatcher's live-reload path funnels every recompile through
1794+
// addThemeProps -> buildTheme without first clearing themeConstants.
1795+
// When the user replaces a `var()`-bound CSS rule with a literal, the
1796+
// recompiled theme.res carries the new style value but no longer emits
1797+
// the matching `@cn1-bind:<key>` entry. The stale binding left over in
1798+
// themeConstants would otherwise let applyThemeBindings stomp the
1799+
// user's literal change with the previous binding's resolved value.
1800+
// Drop any binding whose subject key is being re-set by this load
1801+
// unless the same load also re-asserts the binding.
1802+
java.util.List<String> bindingsToDrop = null;
1803+
Enumeration ek = themeProps.keys();
1804+
while (ek.hasMoreElements()) {
1805+
String key = (String) ek.nextElement();
1806+
if (key.startsWith("@")) {
1807+
continue;
1808+
}
1809+
String boundConstant = "cn1-bind:" + key;
1810+
if (themeConstants.containsKey(boundConstant)
1811+
&& !themeProps.containsKey("@" + boundConstant)) {
1812+
if (bindingsToDrop == null) {
1813+
bindingsToDrop = new java.util.ArrayList<String>();
1814+
}
1815+
bindingsToDrop.add(boundConstant);
1816+
}
1817+
}
17931818
Enumeration e = themeProps.keys();
17941819
while (e.hasMoreElements()) {
17951820
String key = (String) e.nextElement();
@@ -1801,6 +1826,11 @@ private void buildTheme(Hashtable themeProps) {
18011826
}
18021827
this.themeProps.put(key, themeProps.get(key));
18031828
}
1829+
if (bindingsToDrop != null) {
1830+
for (String stale : bindingsToDrop) {
1831+
themeConstants.remove(stale);
1832+
}
1833+
}
18041834

18051835
applyThemeBindings();
18061836

Ports/JavaSE/src/com/codename1/impl/javase/util/MavenUtils.java

Lines changed: 76 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,14 @@
88
import com.codename1.io.Log;
99
import com.codename1.ui.Display;
1010
import java.io.File;
11+
import java.io.FileInputStream;
12+
import java.io.FileOutputStream;
13+
import java.io.IOException;
14+
import java.io.InputStream;
15+
import java.io.OutputStream;
1116
import java.net.URL;
17+
import java.util.zip.ZipEntry;
18+
import java.util.zip.ZipInputStream;
1219

1320
/**
1421
*
@@ -85,7 +92,20 @@ public static File findDesignerJarInM2() {
8592
if (location == null) {
8693
return null;
8794
}
88-
File coreJar = new File(location.toURI());
95+
return findDesignerJarInM2(new File(location.toURI()));
96+
} catch (Throwable t) {
97+
// Best-effort lookup. Any unexpected layout means we can't resolve via m2.
98+
}
99+
return null;
100+
}
101+
102+
/**
103+
* Test seam for {@link #findDesignerJarInM2()}: takes the codenameone-core jar
104+
* path explicitly so the resolution logic can be exercised against a fake m2
105+
* layout in a temp directory.
106+
*/
107+
static File findDesignerJarInM2(File coreJar) {
108+
try {
89109
// Expected layout: <repo>/com/codenameone/codenameone-core/<version>/codenameone-core-<version>.jar
90110
File versionDir = coreJar.getParentFile();
91111
if (versionDir == null) return null;
@@ -98,16 +118,68 @@ public static File findDesignerJarInM2() {
98118
}
99119
String version = versionDir.getName();
100120
File designerVersionDir = new File(codenameoneGroupDir, "codenameone-designer" + File.separator + version);
101-
File designer = new File(designerVersionDir, "codenameone-designer-" + version + "-jar-with-dependencies.jar");
102-
if (designer.isFile()) {
103-
return designer;
121+
// The published jar-with-dependencies artifact is *not* directly runnable:
122+
// maven/designer/pom.xml's antrun step renames the shaded jar to
123+
// designer_1.jar and re-zips it, so this file is a zip wrapper containing
124+
// a single designer_1.jar entry with no top-level Main-Class manifest.
125+
// AbstractCN1Mojo.getDesignerJar (in the maven plugin) unzips it on demand
126+
// and returns the inner jar; we mirror that here so the CSSWatcher
127+
// fallback path receives a path that `java -jar` can actually launch.
128+
File wrapperZip = new File(designerVersionDir, "codenameone-designer-" + version + "-jar-with-dependencies.jar");
129+
if (!wrapperZip.isFile()) {
130+
return null;
131+
}
132+
File extracted = new File(wrapperZip.getParentFile(), wrapperZip.getName() + "-extracted");
133+
File innerJar = new File(extracted, "designer_1.jar");
134+
if (!innerJar.isFile() || innerJar.lastModified() < wrapperZip.lastModified()) {
135+
extractInnerJar(wrapperZip, extracted);
136+
}
137+
if (innerJar.isFile()) {
138+
return innerJar;
104139
}
105140
} catch (Throwable t) {
106141
// Best-effort lookup. Any unexpected layout means we can't resolve via m2.
107142
}
108143
return null;
109144
}
110145

146+
private static void extractInnerJar(File wrapperZip, File destDir) throws IOException {
147+
if (!destDir.exists() && !destDir.mkdirs() && !destDir.isDirectory()) {
148+
throw new IOException("Could not create designer extraction directory: " + destDir.getAbsolutePath());
149+
}
150+
InputStream in = new FileInputStream(wrapperZip);
151+
try {
152+
ZipInputStream zis = new ZipInputStream(in);
153+
try {
154+
ZipEntry entry;
155+
while ((entry = zis.getNextEntry()) != null) {
156+
if (entry.isDirectory()) {
157+
continue;
158+
}
159+
File out = new File(destDir, entry.getName());
160+
File parent = out.getParentFile();
161+
if (parent != null && !parent.exists() && !parent.mkdirs() && !parent.isDirectory()) {
162+
throw new IOException("Could not create directory: " + parent.getAbsolutePath());
163+
}
164+
OutputStream fos = new FileOutputStream(out);
165+
try {
166+
byte[] buf = new byte[8192];
167+
int n;
168+
while ((n = zis.read(buf)) > 0) {
169+
fos.write(buf, 0, n);
170+
}
171+
} finally {
172+
fos.close();
173+
}
174+
}
175+
} finally {
176+
zis.close();
177+
}
178+
} finally {
179+
in.close();
180+
}
181+
}
182+
111183
public static boolean isRunningInJDK() {
112184
if (!isRunningInJDKChecked) {
113185
isRunningInJDKChecked = true;

maven/core-unittests/src/test/java/com/codename1/ui/plaf/UIManagerThemeBindingsTest.java

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,4 +126,77 @@ public void invalidColorOverrideLeavesDefaultIntact() {
126126
b.setUIID("Button");
127127
assertEquals(0x007aff, b.getUnselectedStyle().getFgColor());
128128
}
129+
130+
/// Simulates CSSWatcher's live-reload sequence: an initial theme load
131+
/// (`setThemeProps`) followed by `addThemeProps` carrying a fresh
132+
/// theme.res whose source CSS has dropped a `var()` reference in favor of
133+
/// a literal. The reloaded Hashtable contains the new literal value for
134+
/// `Button.fgColor` but no `@cn1-bind:Button.fgColor` entry, because the
135+
/// CSS compiler only emits bindings for properties that still reference a
136+
/// `var()`. The stale binding left in `themeConstants` from the first
137+
/// load must not stomp the user's new literal value.
138+
@Test
139+
public void cssReloadDropsStaleBindingWhenRuleBecomesLiteral() {
140+
Hashtable initial = new Hashtable();
141+
initial.put("Button.fgColor", "ff0000");
142+
initial.put("@cn1-bind:Button.fgColor", "accent-color");
143+
initial.put("@accent-color", "ff0000");
144+
UIManager.getInstance().setThemeProps(initial);
145+
146+
Hashtable reloaded = new Hashtable();
147+
reloaded.put("Button.fgColor", "0000ff");
148+
UIManager.getInstance().addThemeProps(reloaded);
149+
150+
Button b = new Button();
151+
b.setUIID("Button");
152+
assertEquals(0x0000ff, b.getUnselectedStyle().getFgColor());
153+
}
154+
155+
/// Companion to [#cssReloadDropsStaleBindingWhenRuleBecomesLiteral]: when
156+
/// the reload Hashtable carries BOTH the property and a fresh binding,
157+
/// the binding still applies. This guards against an over-eager fix that
158+
/// would drop bindings every time a style key shows up in the reload.
159+
@Test
160+
public void cssReloadKeepsBindingWhenStillEmittedTogether() {
161+
Hashtable initial = new Hashtable();
162+
initial.put("Button.fgColor", "ff0000");
163+
initial.put("@cn1-bind:Button.fgColor", "accent-color");
164+
initial.put("@accent-color", "ff0000");
165+
UIManager.getInstance().setThemeProps(initial);
166+
167+
Hashtable reloaded = new Hashtable();
168+
reloaded.put("Button.fgColor", "0000ff");
169+
reloaded.put("@cn1-bind:Button.fgColor", "accent-color");
170+
reloaded.put("@accent-color", "00ff00");
171+
UIManager.getInstance().addThemeProps(reloaded);
172+
173+
Button b = new Button();
174+
b.setUIID("Button");
175+
assertEquals(0x00ff00, b.getUnselectedStyle().getFgColor());
176+
}
177+
178+
/// A pure override Hashtable (no style keys, only a single `@varname`
179+
/// constant) must not invalidate the existing bindings. This is the
180+
/// canonical "user rebrands the accent" call path and the existing
181+
/// [#boundThemeKeyPicksUpAccentOverride] covers a single hop; this test
182+
/// adds a follow-up override to make sure repeated retunes keep working.
183+
@Test
184+
public void overrideOnlyReloadKeepsBindings() {
185+
Hashtable initial = new Hashtable();
186+
initial.put("Button.fgColor", "007aff");
187+
initial.put("@cn1-bind:Button.fgColor", "accent-color");
188+
UIManager.getInstance().setThemeProps(initial);
189+
190+
Hashtable firstOverride = new Hashtable();
191+
firstOverride.put("@accent-color", "ff2d95");
192+
UIManager.getInstance().addThemeProps(firstOverride);
193+
194+
Hashtable secondOverride = new Hashtable();
195+
secondOverride.put("@accent-color", "00aa66");
196+
UIManager.getInstance().addThemeProps(secondOverride);
197+
198+
Button b = new Button();
199+
b.setUIID("Button");
200+
assertEquals(0x00aa66, b.getUnselectedStyle().getFgColor());
201+
}
129202
}

maven/javase/pom.xml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,17 @@
125125
</resource>
126126
</resources>
127127
<plugins>
128+
<plugin>
129+
<!-- The parent pom pins surefire to 2.21.0 which lacks the
130+
JUnit Jupiter provider this module's tests rely on (the
131+
pre-existing CSSWatcherTest and the MavenUtilsTest added
132+
alongside the CSSWatcher live-reload fixes). Bump to a
133+
surefire version with native Jupiter discovery so these
134+
tests actually execute. -->
135+
<groupId>org.apache.maven.plugins</groupId>
136+
<artifactId>maven-surefire-plugin</artifactId>
137+
<version>3.2.5</version>
138+
</plugin>
128139
<plugin>
129140
<artifactId>maven-antrun-plugin</artifactId>
130141
<executions>

0 commit comments

Comments
 (0)