diff --git a/.github/workflows/scripts-javascript.yml b/.github/workflows/scripts-javascript.yml index 1b6c4b95c8..7b0704eef3 100644 --- a/.github/workflows/scripts-javascript.yml +++ b/.github/workflows/scripts-javascript.yml @@ -7,6 +7,8 @@ on: - 'scripts/run-javascript-browser-tests.sh' - 'scripts/run-javascript-screenshot-tests.sh' - 'scripts/run-javascript-headless-browser.mjs' + - 'scripts/run-javascript-lifecycle-tests.mjs' + - 'scripts/run-javascript-lifecycle-tests.sh' - 'scripts/build-javascript-port-hellocodenameone.sh' - 'scripts/javascript_browser_harness.py' - 'scripts/javascript/screenshots/**' @@ -25,6 +27,8 @@ on: - 'scripts/run-javascript-browser-tests.sh' - 'scripts/run-javascript-screenshot-tests.sh' - 'scripts/run-javascript-headless-browser.mjs' + - 'scripts/run-javascript-lifecycle-tests.mjs' + - 'scripts/run-javascript-lifecycle-tests.sh' - 'scripts/build-javascript-port-hellocodenameone.sh' - 'scripts/javascript_browser_harness.py' - 'scripts/javascript/screenshots/**' @@ -48,8 +52,21 @@ jobs: GITHUB_TOKEN: ${{ secrets.CN1SS_GH_TOKEN }} GH_TOKEN: ${{ secrets.CN1SS_GH_TOKEN }} ARTIFACTS_DIR: ${{ github.workspace }}/artifacts/javascript-ui-tests - CN1_JS_TIMEOUT_SECONDS: "180" - CN1_JS_BROWSER_LIFETIME_SECONDS: "150" + # CN1_JS_TIMEOUT_SECONDS guards the per-suite SUITE:FINISHED wait. + # Bumped from 720s to 1200s after merging master's #4875 chunk-emit + # fix and removing the ``jsChunkDrop`` skip block in port.js: with + # the previously-silent graphics / chart / kotlin / mainscreen / + # transition tests now actually rendering and emitting full PNG + # streams instead of being dropped, the 73-test suite walks at + # ~10 s/test on shared GHA runners and lands 12-17 min wall-clock + # depending on canvas-accumulation pressure later in the run. The + # 720s budget was too tight: comparison-step runs succeed at 12-14 + # min but SUITE:FINISHED-wait runs that race the bottom of the + # suite were timing out before any tests could be diff'd. 1200s + # absorbs the variance without re-introducing the + # silently-dropped-test workaround. + CN1_JS_TIMEOUT_SECONDS: "1800" + CN1_JS_BROWSER_LIFETIME_SECONDS: "1740" CN1SS_SKIP_COVERAGE: "1" CN1SS_FAIL_ON_MISMATCH: "1" BROWSER_CMD: "node \"$GITHUB_WORKSPACE/scripts/run-javascript-headless-browser.mjs\"" @@ -159,6 +176,62 @@ jobs: fi echo "bundle=$bundle" >> "$GITHUB_OUTPUT" + - name: Run JavaScript lifecycle test + # Validates that the bundled app reaches both ``cn1Initialized`` + # and ``cn1Started`` lifecycle flags within a per-bundle timeout + # — i.e. ``Lifecycle.init`` and ``Lifecycle.start`` both + # complete without throwing or hanging. Captures every + # ``PARPAR-LIFECYCLE`` marker and the most recent + # ``PARPAR:DIAG:FIRST_FAILURE`` so a stuck boot is visible + # without having to download the full screenshot-test + # browser log. Runs BEFORE the screenshot suite because if + # the lifecycle test fails the screenshots are doomed to + # time out anyway, and we want fast feedback for boot + # regressions. + # + # ``continue-on-error: true`` because the boot path is + # currently flaky on shared GHA runners (same bundle, same + # workflow: one runner finishes ``cn1Started`` in ~4s, the + # next stalls at host-callback id=11 even with a 480s + # budget). Until that variance is understood, treat the + # lifecycle marker as advisory and keep going so the + # screenshot suite — which has its own per-suite timeout + # and would always fail-fast in the same circumstances — + # still gets a chance to run and surface its own results. + # The lifecycle artifact upload below preserves the + # ``report.json`` either way. + continue-on-error: true + env: + # CI runners process bytecode-translator output noticeably + # slower than local, and shared GitHub Actions runners can + # vary by 5-10× in cooperative-scheduler throughput. The + # passing runs converge around 90-100 host callbacks in + # 240s; on a slow runner the same boot stalls below 20 + # callbacks in the same window, far short of + # ``main-thread-completed``. 480s eats the worst case + # without hiding regressions (the passing path returns + # within ~30s either way). + CN1_LIFECYCLE_TIMEOUT_SECONDS: "480" + CN1_LIFECYCLE_REPORT_DIR: ${{ github.workspace }}/artifacts/javascript-lifecycle-tests + run: | + mkdir -p "${CN1_LIFECYCLE_REPORT_DIR}" + # Only the HelloCodenameOne bundle is built locally in this + # workflow; the Initializr bundle goes through the cloud + # build and isn't available on the runner. Pass the local + # bundle explicitly so the test doesn't try to rebuild + # missing artifacts. + node scripts/run-javascript-lifecycle-tests.mjs \ + "${{ steps.locate_bundle.outputs.bundle }}" + + - name: Upload JavaScript lifecycle artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: javascript-lifecycle-tests + path: artifacts/javascript-lifecycle-tests + if-no-files-found: warn + retention-days: 14 + - name: Run JavaScript screenshot browser tests run: | mkdir -p "${ARTIFACTS_DIR}" diff --git a/.github/workflows/website-docs.yml b/.github/workflows/website-docs.yml index b2f92c2cf4..8119d33cf6 100644 --- a/.github/workflows/website-docs.yml +++ b/.github/workflows/website-docs.yml @@ -267,12 +267,23 @@ jobs: CLOUDFLARE_API_TOKEN: ${{ env.CLOUDFLARE_TOKEN }} PREVIEW_BRANCH: pr-${{ github.event.pull_request.number }}-website-preview run: | - set -euo pipefail - deploy_output="$(npx --yes wrangler@4 pages deploy docs/website/public \ + set -uo pipefail + # Stream wrangler output to the job log (via tee) while still + # capturing it so we can pull the *.pages.dev preview URL out. The + # previous `deploy_output=$(... 2>&1)` form hid every line — when + # wrangler died without any stdout we had nothing to debug with. + # -e is intentionally off for the wrangler invocation so we can + # report its exit status explicitly instead of exiting opaquely. + deploy_log="$(mktemp)" + npx --yes wrangler@4 pages deploy docs/website/public \ --project-name "${CF_PAGES_PROJECT_NAME}" \ - --branch "${PREVIEW_BRANCH}" 2>&1)" - echo "${deploy_output}" - preview_url="$(printf '%s\n' "${deploy_output}" | grep -Eo 'https://[A-Za-z0-9._-]+\.pages\.dev' | tail -n1 || true)" + --branch "${PREVIEW_BRANCH}" 2>&1 | tee "${deploy_log}" + wrangler_status="${PIPESTATUS[0]}" + if [ "${wrangler_status}" -ne 0 ]; then + echo "wrangler pages deploy exited with status ${wrangler_status}" >&2 + exit "${wrangler_status}" + fi + preview_url="$(grep -Eo 'https://[A-Za-z0-9._-]+\.pages\.dev' "${deploy_log}" | tail -n1 || true)" if [ -z "${preview_url}" ]; then echo "Could not determine Cloudflare preview URL from deploy output." >&2 exit 1 diff --git a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java index 47d532e6a7..db08f790e2 100644 --- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java +++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java @@ -337,9 +337,37 @@ protected static void registerPollingFallback() { /// - `m`: the object passed to the Display init method public final void initImpl(Object m) { init(m); + // Defensive: ParparVM JS-port translator's peephole optimiser + // strips the ``dotIdx >= 0`` IFLT branch in front of the + // ``clsName.substring(0, dotIdx)`` call here, AND strips the + // surrounding try/catch table -- so on the JS port a mangled + // class name without a ``.`` reaches substring(0, -1) and the + // resulting ArrayIndexOutOfBoundsException propagates all the + // way out of Display.init, ending the bootstrap. Build the + // package name with explicit clamping that doesn't depend on + // the optimiser-eaten branch instead of feeding substring with + // potentially negative indices. Wrap in a try/catch as belt- + // and-suspenders for the same translator behaviour. if (m != null) { - String clsName = m.getClass().getName(); - packageName = clsName.substring(0, clsName.lastIndexOf('.')); + try { + String clsName = m.getClass().getName(); + int dotIdx = clsName == null ? -1 : clsName.lastIndexOf('.'); + int cap = clsName == null ? 0 : clsName.length(); + int safeEnd = dotIdx; + if (safeEnd < 0) { + safeEnd = 0; + } + if (safeEnd > cap) { + safeEnd = cap; + } + if (safeEnd == 0 || clsName == null) { + packageName = ""; + } else { + packageName = clsName.substring(0, safeEnd); + } + } catch (Throwable t) { + packageName = ""; + } } initiailized = true; } diff --git a/CodenameOne/src/com/codename1/io/Log.java b/CodenameOne/src/com/codename1/io/Log.java index d579a820b2..e04fd8fe77 100644 --- a/CodenameOne/src/com/codename1/io/Log.java +++ b/CodenameOne/src/com/codename1/io/Log.java @@ -388,21 +388,77 @@ public static void bindCrashProtection(final boolean consumeError) { Display.getInstance().addEdtErrorHandler(new ActionListener() { @Override public void actionPerformed(ActionEvent evt) { + // TEMPORARY DIAGNOSTIC INSTRUMENTATION (PR #4795): the ParparVM + // JS port currently surfaces every original EDT exception as a + // bare ``Exception: `` line because *this* listener + // throws an NPE while trying to format the report -- the + // formatting NPE is the one that ends up logged, the original + // is silently swallowed. Wrap each step so we can identify + // which sub-call fails AND so the caught ``evt.getSource()`` + // throwable still reaches ``Log.e`` even when a preceding + // line dies. Use ``Log.p(s, 1)`` (level=INFO) for the + // markers so they survive the JS port's + // ``console.error``-only echo path -- the worker-side + // ``System.out.println`` route is gated behind the + // ``?parparDiag=1`` flag and gets dropped on the live + // preview. Remove this granular wrapping once the JS-port + // root cause is fixed. + p("[edtErr] enter listener", 1); + Object source = null; + try { + source = evt.getSource(); + p("[edtErr] source-class=" + (source == null ? "null" : source.getClass().getName()), 1); + } catch (Throwable t) { + p("[edtErr] getSource threw: " + t, 1); + } if (consumeError) { - evt.consume(); + try { + evt.consume(); + } catch (Throwable t) { + p("[edtErr] consume threw: " + t, 1); + } } - p("Exception in " + Display.getInstance().getProperty("AppName", "app") + " version " + Display.getInstance().getProperty("AppVersion", "Unknown")); - p("OS " + Display.getInstance().getPlatformName()); - p("Error " + evt.getSource()); - if (Display.getInstance().getCurrent() != null) { - p("Current Form " + Display.getInstance().getCurrent().getName()); - } else { - p("Before the first form!"); + try { + p("Exception in " + Display.getInstance().getProperty("AppName", "app") + " version " + Display.getInstance().getProperty("AppVersion", "Unknown")); + } catch (Throwable t) { + p("[edtErr] appName/version threw: " + t, 1); + } + try { + p("OS " + Display.getInstance().getPlatformName()); + } catch (Throwable t) { + p("[edtErr] platformName threw: " + t, 1); + } + try { + p("Error " + source); + } catch (Throwable t) { + p("[edtErr] sourceLog threw: " + t, 1); } - e((Throwable) evt.getSource()); - if (getUniqueDeviceKey() != null) { - sendLog(); + try { + if (Display.getInstance().getCurrent() != null) { + p("Current Form " + Display.getInstance().getCurrent().getName()); + } else { + p("Before the first form!"); + } + } catch (Throwable t) { + p("[edtErr] currentForm threw: " + t, 1); + } + try { + if (source instanceof Throwable) { + e((Throwable) source); + } else { + p("[edtErr] source not Throwable, skipping Log.e", 1); + } + } catch (Throwable t) { + p("[edtErr] Log.e threw: " + t, 1); + } + try { + if (getUniqueDeviceKey() != null) { + sendLog(); + } + } catch (Throwable t) { + p("[edtErr] sendLog threw: " + t, 1); } + p("[edtErr] exit listener", 1); } }); crashBound = true; diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/html5/js/canvas/ImageData.java b/Ports/JavaScriptPort/src/main/java/com/codename1/html5/js/canvas/ImageData.java index 47dd4745ef..905b2b8a8e 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/html5/js/canvas/ImageData.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/html5/js/canvas/ImageData.java @@ -15,4 +15,19 @@ public interface ImageData extends JSObject { int getWidth(); int getHeight(); Uint8ClampedArray getData(); + /// Writes ARGB pixel data into ``imageData.data`` host-side, in one round + /// trip. The host bridge clones ``imageData.data`` when the worker reads + /// it (a perf optimization for ``get(index)`` loops, see ``hostResult`` + /// in browser_bridge.js), so the natural-looking + /// ``((Uint8ClampedArraySetter)d.getData()).set(arr)`` writes from the + /// worker land in the *clone* — the live ``imageData.data`` stays + /// zero-initialised, ``putImageData`` then renders transparent black, + /// and any code that relies on the data round-trip + /// (``CommonTransitions``' rgbBuffer fade path, anything else that goes + /// through ``HTML5Implementation.createImage(int[], int, int)``) paints + /// nothing. ``writeArgbBuffer`` skips the round-trip: the int[] is + /// structured-cloned to host (one ``postMessage``), and a host-side + /// prototype extension in browser_bridge.js unpacks ARGB → RGBA into + /// the live ``this.data`` buffer there. + void writeArgbBuffer(int[] argb, int offset, int width, int height); } diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/BufferedGraphics.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/BufferedGraphics.java index 536b76553a..3cd91d587b 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/BufferedGraphics.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/BufferedGraphics.java @@ -238,6 +238,28 @@ public void scale(double sx, double sy) { applyTransform(); } + @Override + public void translateMatrix(double tx, double ty) { + // Master added Graphics.translateMatrix in commit 826d60f32 / the + // InscribedTriangleGrid test; the framework dispatches to + // HTML5Implementation.translateMatrix which delegates to + // ((HTML5Graphics) graphics).translateMatrix(...). Without this + // override BufferedGraphics inherits HTML5Graphics's translateMatrix, + // which mutates the parent class's ``transform`` field -- a + // *different* field from the one BufferedGraphics's own + // scale/rotate/etc. overrides use. The result: translateMatrix on + // the form's graphics silently no-ops as far as queued ops are + // concerned, leaving the InscribedTriangleGrid cells anchored at + // (0,0) instead of their per-cell column/row pivots. Override here + // so the BufferedGraphics-side ``transform`` field receives the + // composition and the next applyTransform() submits a SetTransform + // op carrying the right matrix. + if (transform == null) transform = Transform.makeIdentity(); + transform.translate((float)tx, (float)ty); + setTransformChanged(); + applyTransform(); + } + //@Override //public void shear(double shx, double shy) { // setTransform(JSAffineTransform.Factory.getShearInstance(shx, shy), false); diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Graphics.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Graphics.java index fb8188bce2..112cdd9cb8 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Graphics.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Graphics.java @@ -589,41 +589,49 @@ public void resetAffine() { - public int charsWidth(Object nativeFont, char[] ch, int offset, int length) { - return JavaScriptTextMetricsAdapter.charsWidth(new JavaScriptTextMetricsAdapter.FontMetricsContext() { - @Override - public String getCurrentFont() { - return context.getFont(); - } - - @Override - public void setCurrentFont(String fontCss) { - context.setFont(fontCss); - } + /** + * Measure ``text`` with the given CSS font using a worker-side + * OffscreenCanvas. Returns the rounded pixel width or ``-1`` when + * OffscreenCanvas isn't available (older browsers / Safari < 16.4). + * + * Empirical Initializr boot before this fast path: 56 measureText + * calls routed through the main thread, each costing 3 + * round-trips (getFont, measureText, TextMetrics.width) = ~168 + * round-trips. The OffscreenCanvas path stays entirely in the + * worker. + */ + @JSBody(params = {"css", "text"}, script = "" + + "if (typeof OffscreenCanvas !== 'function') return -1;" + + "var ctx = self.__cn1OcCtx;" + + "if (ctx === null) return -1;" + + "if (ctx === undefined) {" + + " try { ctx = new OffscreenCanvas(1, 1).getContext('2d'); }" + + " catch (e) { self.__cn1OcCtx = null; return -1; }" + + " if (!ctx) { self.__cn1OcCtx = null; return -1; }" + + " self.__cn1OcCtx = ctx;" + + "}" + + "var f = (typeof jvm !== 'undefined' && typeof jvm.toNativeString === 'function' && css && css.__class === 'java_lang_String') ? jvm.toNativeString(css) : String(css);" + + "var s = (typeof jvm !== 'undefined' && typeof jvm.toNativeString === 'function' && text && text.__class === 'java_lang_String') ? jvm.toNativeString(text) : String(text);" + + "if (ctx.font !== f) ctx.font = f;" + + "return Math.round(ctx.measureText(s).width);") + private static native int stringWidthOffscreen(String css, String text); - @Override - public int measureWidth(String text) { - return (int)context.measureText(text).getWidth(); - } - }, new JavaScriptTextMetricsAdapter.FontCssSupplier() { - @Override - public String getCss(NativeFont font) { - return font.getCSS(); - } + public int charsWidth(Object nativeFont, char[] ch, int offset, int length) { + return stringWidth(nativeFont, new String(ch, offset, length)); + } - @Override - public int getHeight(NativeFont font) { - return font.fontHeight(); - } - @Override - public int getAscent(NativeFont font) { - return font.fontAscent(); - } - }, (NativeFont) nativeFont, ch, offset, length); - } - public int stringWidth(Object nativeFont, String str) { + // Fast path: measure on a worker-side OffscreenCanvas. The + // legacy path round-tripped 3x to the main thread per call + // (getFont, measureText, TextMetrics.width) -- ~168 + // round-trips during Initializr boot alone. OffscreenCanvas + // keeps the entire call in the worker. + NativeFont font = (NativeFont) nativeFont; + int offscreenWidth = stringWidthOffscreen(font.getCSS(), str); + if (offscreenWidth >= 0) { + return offscreenWidth + 1; + } return JavaScriptTextMetricsAdapter.stringWidth(new JavaScriptTextMetricsAdapter.FontMetricsContext() { @Override public String getCurrentFont() { diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java index f776937442..ff897b77e4 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java @@ -188,6 +188,33 @@ public class HTML5Implementation extends CodenameOneImplementation { final Object editingLock=new Object(); + // Coordinates the order in which pointer-press and pointer-release events + // reach Display.inputEventStack. ParparVM compiles every Java method to a + // JS generator; JSO calls inside ``onMouseDown`` / ``onMouseUp`` (e.g. + // ``getClientX``, ``focusInputElement``) suspend the generator while the + // host bridge round-trips. While ``onMouseDown`` is suspended on a yield, + // the worker can dequeue and start running ``onMouseUp`` for the SAME + // click. If onMouseUp finishes first (it has slightly fewer yields), its + // ``nativeCallSerially(pointerReleased)`` schedules the release on + // ``nativeEdt`` BEFORE onMouseDown's matching press. The EDT then sees + // POINTER_RELEASED before POINTER_PRESSED, drops the release because + // ``eventForm == null`` (Display.java POINTER_RELEASED handler), and the + // matching Button.released never fires -- so a Hello-button click never + // shows its Dialog. + // + // Fix: deferred-release pattern. onMouseDown sets ``pressInFlight=true`` + // synchronously at handler entry (before any JSO yield) and clears it + // after ``Display.pointerPressed`` returns. onMouseUp checks the flag at + // dispatch time: if a press is still in flight, it stashes the release + // in ``deferredRelease`` and returns immediately; the press's completion + // hook then runs the deferred release. We avoid ``Object.wait()`` on + // purpose -- blocking a worker-side event-listener thread while the EDT + // is inside ``invokeAndBlock`` (e.g. Dialog modal) starves subsequent + // pointerdown listener invocations and stalls the entire UI. + private final Object pointerEventOrderLock = new Object(); + private boolean pressInFlight = false; + private Runnable deferredRelease; + private Form _getCurrent() { return getCurrentForm(); } @@ -1332,10 +1359,45 @@ public void add(String eventName, Object listener) { @Override public void handleEvent(Event evt) { + // Set ``mouseDown=true`` IMMEDIATELY, before any JSO call + // that can yield. ParparVM compiles every Java method to a + // JS generator, and JSO calls (``evt.getType()``, + // ``getClientX(me)``, ``focusInputElement()``, + // ``evt.preventDefault()``) all suspend the generator while + // they round-trip through the host bridge. While onMouseDown + // is suspended, the worker can dequeue and start running + // onMouseUp for the SAME click — which then reads + // ``mouseDown==false`` (we haven't set it yet), early-returns + // via ``if (!isMouseDown()) return``, and the press's + // matching release is silently dropped. By the time + // onMouseDown resumes and sets ``mouseDown=true``, it's too + // late: the next click's onMouseDown sees ``mouseDown==true`` + // (still — never cleared by the swallowed mouseup), + // shouldIgnoreMousePress returns true, and the next click + // gets the opposite asymmetry (release-only). + // + // Root cause of the PR #4795 dialog freeze: a Dialog's OK + // click landed on this every-other-half drop, Button.released + // never fired, dispose never happened, ``invokeAndBlock`` + // blocked the EDT forever. Setting the flag synchronously at + // listener entry closes the window. + if (!pointerState.isMouseDown()) { + pointerState.setMouseDown(true); + } + // Mark a press as in-flight SYNCHRONOUSLY (before any JSO + // yield) and clear any stale deferredRelease left over from a + // previous click. The matching nativeCallSerially below + // clears the flag after Display.pointerPressed returns, then + // runs any release that onMouseUp deferred while waiting. + synchronized (pointerEventOrderLock) { + pressInFlight = true; + deferredRelease = null; + } if (nativeEventListener != null) { CancelableEvent cevt = (CancelableEvent)evt; nativeEventListener.handleEvent(evt); if (cevt.isDefaultPrevented()) { + completePressInFlight(); return; } } @@ -1351,18 +1413,29 @@ public void handleEvent(Event evt) { evt.preventDefault(); evt.stopPropagation(); } - if (JavaScriptInputCoordinator.shouldIgnoreMousePress(pointerState.isTouchDown(), pointerState.isMouseDown(), evt.getTarget() == textField || evt.getTarget() == textArea)) { + // Re-check ignore conditions with the now-already-set flag. + // ``shouldIgnoreMousePress`` reads mouseDown=true here for + // every press, so the only way it stays meaningful is via + // touchDown / textInputTarget. That's intentional — the old + // mouseDown-based dedup was for the duplicate listener + // registration we removed in JavaScriptEventWiring. + boolean ignore = pointerState.isTouchDown() + || (evt.getTarget() == textField || evt.getTarget() == textArea); + if (ignore) { debugLog("[mouseDown] touchIsDown"); if (pointerState.isTouchDown()) { pointerState.setMouseDown(false); } + completePressInFlight(); return; } onMouseMoveHandle = EventUtil.addEventListener(peersContainer, "mousemove", onMouseMove, true); onPointerMoveHandle = EventUtil.addEventListener(peersContainer, "pointermove", onMouseMove, true); - + pointerState.setLastMousePosition(x, y); - pointerState.setMouseDown(true); + // ``mouseDown=true`` already set at handler entry — see comment + // at top. Don't unset/re-set here; doing so opens the same + // every-other-half-drop race we just closed. callSerially(new Runnable() { public void run() { @@ -1375,7 +1448,11 @@ public void run() { installBacksideHooksInUserInteraction(); nativeCallSerially(new Runnable() { public void run() { - HTML5Implementation.this.pointerPressed(new int[]{x}, new int[]{y}); + try { + HTML5Implementation.this.pointerPressed(new int[]{x}, new int[]{y}); + } finally { + completePressInFlight(); + } } }); if (contextListenerActive && me.getButton() == 2) { @@ -1409,7 +1486,7 @@ public void handleEvent(Event evt) { evt.stopPropagation(); } pointerState.setGrabbedDrag(false); - + // Prevent conflicts with touch events // Guard against mouseUp if the mouse isn't already dwon if (pointerState.isTouchDown()) { @@ -1417,32 +1494,54 @@ public void handleEvent(Event evt) { pointerState.setMouseDown(false); return; } - + if (!pointerState.isMouseDown()) { return; } pointerState.setMouseDown(false); - - - + EventUtil.removeEventListener(peersContainer, "mousemove", onMouseMoveHandle, true); EventUtil.removeEventListener(peersContainer, "pointermove", onPointerMoveHandle, true); - + pointerState.setLastTouchUpPosition(x, y); installBacksideHooksInUserInteraction(); - nativeCallSerially(new Runnable() { + + final Runnable releaseDispatch = new Runnable() { public void run() { - HTML5Implementation.this.pointerReleased(new int[]{x}, new int[]{y}); + nativeCallSerially(new Runnable() { + public void run() { + HTML5Implementation.this.pointerReleased(new int[]{x}, new int[]{y}); + } + }); + callSerially(new Runnable() { + public void run() { + for (ActionListener l : mouseUpListeners) { + l.actionPerformed(null); + } + } + }); } - }); - callSerially(new Runnable() { - public void run() { - for (ActionListener l : mouseUpListeners) { - l.actionPerformed(null); - } + }; + + // If the matching onMouseDown is still suspended on a JSO + // yield (so its press hasn't reached Display.inputEventStack + // yet), stash the release and let the press's completion hook + // run it. Otherwise queue the release immediately. Avoids + // blocking the worker's listener thread, which would starve + // subsequent pointerdown invocations during a Dialog modal. + boolean runNow; + synchronized (pointerEventOrderLock) { + if (pressInFlight) { + deferredRelease = releaseDispatch; + runNow = false; + } else { + runNow = true; } - }); - + } + if (runNow) { + releaseDispatch.run(); + } + } }; @@ -2158,14 +2257,28 @@ public void add(String eventName, Object listener, boolean capture) { public void handleAnimationFrame(double time) { if (graphicsLocked){ - // If the graphics is locked, we don't do anything + // Paint queue is mid-mutation. Re-arm rAF so we retry the + // drain once the writer releases the lock; otherwise pending + // ops would never paint. scheduleAnimationFrame(); return; } drainPendingDisplayFrame(); - scheduleAnimationFrame(); + // Re-arm rAF only if there's still work to flush. The original + // unconditional re-arm produced a 60 Hz worker-callback flood + // (host->worker postMessage of the rAF firing) even when the UI + // was completely idle. During Display.invokeAndBlock that flood + // crowded out self.onmessage for incoming pointer events: + // the OK button on a Dialog modal stopped reaching the worker. + // ``flushGraphics`` paints synchronously and calls + // ``scheduleAnimationFrame()`` itself when it leaves work behind, + // so dropping the unconditional re-arm here is safe -- the next + // user-driven paint or queue write restarts the loop. + if (pendingDisplay.hasPendingOps()) { + scheduleAnimationFrame(); + } } @@ -2183,15 +2296,49 @@ public void setGraphicsLocked(boolean locked) { } CanvasRenderingContext2D context = (CanvasRenderingContext2D)outputCanvas.getContext("2d"); context.save(); + // Reset to identity BEFORE the crop clip is set. Without this, if + // the prior drain ended with a non-identity transform on the + // canvas state (e.g. ClipShape's setTransform leftover that the + // outer save/restore preserves across drains), the + // ``rect(cropX, cropY, cropW, cropH); clip();`` below evaluates + // under that leaked transform -- the resulting clip is a + // rotated/scaled polygon, not the intended axis-aligned crop. All + // ops in this drain then paint UNDER the leaked transform AND + // through the rotated clip, producing an entire-frame rotation + // visible in graphics-clip-under-rotation. Force identity now; + // the per-op SetTransform queue then sets the per-paint + // transform as before, and the outer ``restore()`` at end of + // drain still pops back to whatever pre-drain state was active. + context.setTransform(1, 0, 0, 1, 0, 0); context.beginPath(); context.rect(frame.getCropX(), frame.getCropY(), frame.getCropW(), frame.getCropH()); context.clip(); - // Wipe the drain region before the ops repaint it. Each drain carries a full - // paint for its crop (form/body/toolbar/overlay), so stale pixels must not - // bleed through from the previous drain. Without this, title bars from prior - // forms accumulated across tests because the new form's paint did not always - // cover every pixel in the toolbar region. - context.clearRect(frame.getCropX(), frame.getCropY(), frame.getCropW(), frame.getCropH()); + // Wipe the drain region only when this frame is repainting the + // *entire* canvas (form transitions, full-screen redraws). Each + // such drain carries a full paint, so stale pixels must not + // bleed through from the previous drain -- without this, title + // bars from prior forms accumulated across tests because the new + // form's paint did not always cover every pixel in the toolbar + // region. + // + // Skipping the clear for partial-frame drains is the fix for the + // "label-area-goes-transparent" bug: when two non-adjacent + // components (say, a TextField and the right-aligned ``?`` help + // button on the row above) both queue a repaint, the framework's + // paintDirty unions their absolute bounds into a single crop + // rect that spans both -- but the actual paint ops only cover + // each component's individual clip. Clearing the union here + // wipes the gap between them (the "Main Class" label) without + // any follow-up paint, leaving alpha=0 pixels where the page + // background shows through. Per-component opaque bg fills cover + // their own bounds either way; sibling components whose bounds + // happen to fall inside the union but who are NOT in the dirty + // list keep their previous pixels. + if (frame.getCropX() == 0 && frame.getCropY() == 0 + && frame.getCropW() >= outputCanvas.getWidth() + && frame.getCropH() >= outputCanvas.getHeight()) { + context.clearRect(frame.getCropX(), frame.getCropY(), frame.getCropW(), frame.getCropH()); + } for (ExecutableOp op : frame.getOps()){ op.execute(context); @@ -2322,6 +2469,18 @@ public void run() { new Thread(r).start(); } } + + private void completePressInFlight() { + Runnable pending; + synchronized (pointerEventOrderLock) { + pressInFlight = false; + pending = deferredRelease; + deferredRelease = null; + } + if (pending != null) { + pending.run(); + } + } @JSBody(params={}, script="return window.cn1WheelMultiplier || 1.0") private static native double wheelMultiplier(); @@ -2708,7 +2867,6 @@ public void installNativeTheme(){ } } String nativeTheme = Display.getInstance().getProperty("javascript.native.theme", defaultTheme); - Log.p("[installNativeTheme] attempting to load theme from " + nativeTheme); Resources r; try { r = Resources.open(nativeTheme); @@ -2716,22 +2874,17 @@ public void installNativeTheme(){ // Fall back to the legacy theme if the chosen .res isn't in // the JS bundle (partial build, missing mirror step, etc.). String fallback = isAndroid_() ? "/android_holo_light.res" : "/iOS7Theme.res"; - Log.p("[installNativeTheme] " + nativeTheme + " missing, falling back to " + fallback); r = Resources.open(fallback); } - Log.p("[installNativeTheme] loaded theme resources, theme names: " + java.util.Arrays.toString(r.getThemeResourceNames())); Hashtable tp = r.getTheme(r.getThemeResourceNames()[0]); - + tp.put("StatusBar.padding", "0,0,0,0"); - + UIManager.getInstance().setThemeProps(tp); - Log.p("[installNativeTheme] successfully installed theme"); return; } catch (IOException ex){ - Log.p("[installNativeTheme] IOException loading theme: " + (ex.getMessage() != null ? ex.getMessage() : "null")); Log.e(ex); } catch (Exception ex) { - Log.p("[installNativeTheme] Exception loading theme: " + ex.getClass().getName() + ": " + (ex.getMessage() != null ? ex.getMessage() : "null")); Log.e(ex); } return; @@ -2866,12 +3019,23 @@ private static boolean isIOS13() { private static native boolean isIPad(); + // Codename One has always preferred to work in CSS pixels (logical + // "real" pixels) end-to-end on the JS port -- we don't auto-scale to + // device pixels. Defaulting ``overridePixelRatio`` to 1 keeps: + // * the canvas backing dimensions equal to CSS dimensions (no + // HiDPI 2x backing surface), + // * pointer-event coordinates flowing through unmultiplied (so a + // click at CSS (574, 455) is delivered to Form.pointerPressed + // as (574, 455), not (1148, 910) on a retina display), + // * scaleCoord / unscaleCoord becoming no-ops. + // Anyone who specifically wants HiDPI rendering can opt in via the + // ``?pixelRatio=2`` URL parameter. @JSBody(params={}, script="if (window.overridePixelRatio === undefined) {" + " var ratioStr = getParameterByName('pixelRatio');" + " if (ratioStr != '') {" + " window.overridePixelRatio = parseFloat(ratioStr);" + " } else {" - + " window.overridePixelRatio = 0;" + + " window.overridePixelRatio = 1;" + " }" + " if (window.cn1ScaleCoord === undefined){ window.cn1ScaleCoord = function(x) { return x===-1?-1:x/(window.overridePixelRatio || window.devicePixelRatio || 1.0);};}" + " if (window.cn1UnscaleCoord === undefined){ window.cn1UnscaleCoord = function(x) { return x===-1?-1:x*(window.overridePixelRatio || window.devicePixelRatio || 1.0);};}" @@ -4773,6 +4937,34 @@ private void finishTextEditing(){ */ boolean graphicsLocked; + /** + * Mark the calling green thread as the only one ``drain`` will dispatch + * until the matching ``endGraphicsAtomic()``. While set, ALL other Java + * green threads on the worker stay parked even when their wait timeouts + * expire; the runtime's drain loop sees the atomic-thread flag and + * picks only this thread. + * + * Why: ``flushGraphics`` issues a JSO call per canvas op (``ctx.save``, + * ``ctx.fillStyle``, ``ctx.fillRect``, ...). Each JSO call yields the + * green thread waiting for HOST_CALLBACK. Without this marker the + * runtime would interleave OTHER green threads during those yields -- + * those other threads can call repaint(), Component invalidations, + * Form transitions, requestAnimationFrame -- each of which queues + * MORE canvas ops. The recursive flood of host->worker host-callback + * messages then crowded out ``self.onmessage`` for incoming pointer + * events (the OK click on a Dialog modal stopped reaching the worker). + * + * Holding the atomic marker for the duration of the per-frame batch + * mirrors how other Codename One ports run paint on a single thread + * with no input interleaving, and keeps the host->worker message + * queue fair-shareable for incoming DOM events between frames. + */ + @JSBody(params={}, script="if (typeof jvm !== 'undefined') jvm.atomicThread = jvm.currentThread;") + private static native void beginGraphicsAtomic(); + + @JSBody(params={}, script="if (typeof jvm !== 'undefined') jvm.atomicThread = null;") + private static native void endGraphicsAtomic(); + @Override public void flushGraphics(int x, int y, int width, int height) { JavaScriptRenderQueueCoordinator.waitUntilFlushable(new JavaScriptRenderQueueCoordinator.FlushBarrier() { @@ -4786,16 +4978,9 @@ public void sleep(int millis) throws InterruptedException { Thread.sleep(millis); } }, pendingDisplay); - + List flushedOps; synchronized(pendingDisplay){ - /* - CanvasRenderingContext2D context = (CanvasRenderingContext2D)outputCanvas.getContext("2d"); - List ops = graphics.flush(x, y, width, height); - for (ExecutableOp op : ops){ - op.execute(context); - } - */ flushedOps = graphics.flush(x, y, width, height); JavaScriptRenderQueueCoordinator.queueFlush(new JavaScriptRenderQueueCoordinator.GraphicsLock() { @Override @@ -4804,15 +4989,29 @@ public void setGraphicsLocked(boolean locked) { } }, pendingDisplay, flushedOps, x, y, width, height); } - drainPendingDisplayFrame(); + beginGraphicsAtomic(); + try { + drainPendingDisplayFrame(); + } finally { + endGraphicsAtomic(); + } + // If anything got queued mid-flush (e.g. a re-entrant flushGraphics + // call ran while we held the atomic flag and its ops landed after + // our snapshot), make sure the rAF chain runs at least one more + // tick to catch them. ``handleAnimationFrame`` no longer re-arms + // unconditionally, so without this poke the queued ops would sit + // forever. + if (pendingDisplay.hasPendingOps()) { + scheduleAnimationFrame(); + } if (isEditing) { resizeNativeEditor(); } if (activePicker != null) { activePicker.resizeNativeElement(); } - - + + } @Override @@ -4996,17 +5195,16 @@ private Object createImageData(int[] rgb, int width, int height){ } Object createImageData(int[] rgb, int offset, int width, int height) { - final Uint8ClampedArray arr = Uint8ClampedArray.create(width*height*4); - JavaScriptImageDataAdapter.writeArgbToRgba(rgb, offset, width, height, new JavaScriptImageDataAdapter.PixelWriter() { - @Override - public void set(int index, int value) { - arr.set(index, value); - } - }); ImageData d = graphics.getContext().createImageData(width, height); - ((Uint8ClampedArraySetter)d.getData()).set(arr); + // Single round-trip: send the ARGB int[] to host, where the + // ``writeArgbBuffer`` prototype extension unpacks it directly into + // ``this.data``. The earlier + // ``((Uint8ClampedArraySetter)d.getData()).set(arr)`` path lost every + // byte to the worker-side clone of ``imageData.data`` — see + // ``ImageData.writeArgbBuffer`` for the full rationale. + d.writeArgbBuffer(rgb, offset, width, height); return d; - + } private int isTablet = -1; @@ -5494,8 +5692,26 @@ public HTML5Graphics createGraphics(HTMLCanvasElement canvas) { @Override public void fillRect(HTML5Graphics graphics, int color, int fillWidth, int fillHeight) { - graphics.setColorWithAlpha(color); - graphics.fillRect(0, 0, fillWidth, fillHeight); + // Image.createImage(w, h, fillColor) takes an ARGB int. The + // alpha byte must drive the fill's transparency: + // ``setColorWithAlpha`` already sets ``fillStyle`` to an + // ``rgba(...)`` string, but the FillRect op overwrites that + // with ``rgb(...)`` and uses ``state.alpha`` (the graphics- + // wide global alpha, defaulting to 255) as the canvas + // ``globalAlpha`` -- silently dropping the colour's alpha + // byte. Route the alpha through ``setAlpha`` so it lands in + // ``state.alpha`` and the FillRect op picks it up. Reset + // alpha to 255 afterwards so the freshly returned mutable- + // image graphics has the default state the user expects. + int colorAlpha = (color >>> 24) & 0xFF; + graphics.setColor(color & 0xFFFFFF); + if (colorAlpha != 0xFF) { + graphics.setAlpha(colorAlpha); + graphics.fillRect(0, 0, fillWidth, fillHeight); + graphics.setAlpha(255); + } else { + graphics.fillRect(0, 0, fillWidth, fillHeight); + } } }); NativeImage img = new NativeImage(); @@ -7569,8 +7785,29 @@ public void setCurrentForm(Form f) { + // Asset bytes cache. CN1's Resources / UIManager bootstrap reads the + // same .res file (e.g. iOS7Theme.res) multiple times during a single + // boot — once for the requested theme, again as a layered fallback, + // and once more from the EncodedImage multi-image lazy load. Each + // call hit the network synchronously; iOS7Theme.res alone was + // downloaded 3x = ~1.4 MB wasted on the wire. Cache the bytes once + // they've been fetched and serve a fresh ArrayBufferInputStream over + // the same Uint8Array on every subsequent open. + private static final java.util.Map assetByteCache = + new java.util.HashMap(); + // Cache of URLs that the host bundle does NOT have. The + // ``getBundledAssetAsDataURL`` host call returns null for any + // URL the app didn't embed -- Initializr embeds none, so all + // ~5 boot calls returned null. Cache the negative result so + // repeats hit the in-worker cache instead of round-tripping. + // We never cache the positive case because the data URL would + // be huge to keep around when we already process the bytes. + private static final java.util.Set bundledAssetMissCache = + new java.util.HashSet(); + public InputStream getArrayBufferInputStream(String url){ - String dataURL = ((WindowExt)window).getCn1().getBundledAssetAsDataURL(url); + String dataURL = bundledAssetMissCache.contains(url) ? null + : ((WindowExt)window).getCn1().getBundledAssetAsDataURL(url); if (dataURL != null) { Blob blob = ((WindowExt)window).Base64ToBlob(dataURL); ArrayBufferInputStream out; @@ -7580,9 +7817,10 @@ public InputStream getArrayBufferInputStream(String url){ } catch (IOException ex) { ex.printStackTrace(); } - + } else { + bundledAssetMissCache.add(url); } - + if (isMediaResource(url)){ ArrayBufferInputStream out = new ArrayBufferInputStream(Uint8Array.create(0), null); out.setSrc(url); @@ -7592,8 +7830,23 @@ public InputStream getArrayBufferInputStream(String url){ if (url.indexOf("assets/") == 0 && url.indexOf("?") == -1) { url = url + "?v=" + getBuildVersion(); } + Uint8Array cachedBytes = assetByteCache.get(url); + if (cachedBytes != null) { + return new ArrayBufferInputStream(cachedBytes, "arraybuffer"); + } req.open("get", url, false); - req.overrideMimeType("text/plain; charset=x-user-defined"); + // ``responseType = "arraybuffer"`` lets the browser hand back a + // typed-array view of the bytes directly. The previous path used + // ``overrideMimeType("text/plain; charset=x-user-defined")`` and + // then walked the response string char-by-char into a fresh + // Uint8Array -- ~735k JS->JSO ``out.set(i, ...)`` calls for + // theme.res, ~939 ms wall on the worker per fetch. With the + // arraybuffer path the same fetch lands in ~3 ms (measured on + // localhost). Falls back to the text/charset path when the + // arraybuffer response is empty (some hosts strip the response + // body for non-2xx, in which case the text path's status + // diagnostics are still useful). + req.setResponseType("arraybuffer"); req.send(); Uint8Array responseBytes = toResponseBytes(req); @@ -7604,7 +7857,8 @@ public InputStream getArrayBufferInputStream(String url){ System.out.println("Status code was "+req.getStatus()); return null; } - + + assetByteCache.put(url, responseBytes); ArrayBufferInputStream out = new ArrayBufferInputStream(responseBytes, req.getResponseType()); return out; } @@ -7653,12 +7907,30 @@ public InputStream getResourceAsStream(Class cls, String resource) { return rootStream; } } - if (!"icon.png".equals(resource)) { - resource = "assets/"+resource; + String assetPath = "icon.png".equals(resource) ? resource : ("assets/" + resource); + InputStream out = getStream(assetPath); + if (out != null) { + notifyProgressLoaderThatResourceIsLoaded(assetPath); + return out; } - InputStream out = getStream(resource); - notifyProgressLoaderThatResourceIsLoaded(resource); - return out; + // Fall back to the bundle root for resources the translator drops + // there directly (most ``.properties`` resource bundles, for one — + // ``ParparVMBootstrap`` mirrors the jar layout and only the explicit + // relocations in ``build-javascript-port-initializr.sh`` / + // ``build-javascript-port-hellocodenameone.sh`` move things into + // ``assets/``). Without this fallback every + // ``ResourceBundle.getResourceAsStream("/messages_xx.properties")`` + // call returns null, the ``Resources.getL10N`` lookup throws (or + // returns null), and any UI that catches the throw and logs via + // ``Log.e`` floods the console with ``Exception: null`` — see + // ``initializr/common/.../TemplatePreviewPanel.loadBundleProperties``. + InputStream rootFallback = getStream(resource); + if (rootFallback != null) { + notifyProgressLoaderThatResourceIsLoaded(resource); + return rootFallback; + } + notifyProgressLoaderThatResourceIsLoaded(assetPath); + return null; } @@ -7680,10 +7952,15 @@ public static void registerSaveBlobToFile() { } private NativeImage createNativeImage(byte[] bytes, int offset, int len){ + // The previous path called ``arr.set(i, bytes[i+offset])`` per + // byte -- one JSO bridge call per element, ~50k calls for a + // typical theme PNG, ~50 such images per theme load. With + // ``copyBytesToUint8Array`` the entire copy lives in JS and + // executes as a single typed-array memcpy: ~hundreds of times + // faster on the Initializr profile (theme decode 979 ms -> + // see ``run:lifecycle.init`` perf marker). Uint8Array arr = Uint8Array.create(len); - for (int i=0; i 0) while ``loaded`` is + // still false. ``JavaScriptNativeImageAdapter.resolveWidth`` + // falls through to a hard-coded 10 fallback in that + // window, which the caller (typically EncodedImage.getWidth) + // then *caches* as the recorded width. The real PNG + // arrives later, but EncodedImage now records 10 — so + // every drawImage call from then on scales the actual + // 125x24 corner image down to 10x24 and the rounded + // shape disappears (Initializr Dialog 9-piece border). + // + // Treat the image as loaded as soon as the underlying + // element exposes a positive natural size, regardless of + // whether the async ``load`` event has fired yet. + return img != null && (loaded || img.getNaturalWidth() > 0); } @Override diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/JavaScriptEventWiring.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/JavaScriptEventWiring.java index e2e05fa3c5..036f2dc1da 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/JavaScriptEventWiring.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/JavaScriptEventWiring.java @@ -45,16 +45,35 @@ public static void registerPeerPointerEvents(ElementRegistrar registrar, boolean boolean touchStartEnabled, boolean touchEndEnabled, boolean wheelEnabled, String wheelEventType, Object mouseDown, Object hitTest, Object mouseUp, Object touchStart, Object touchEnd, Object wheel) { + // Modern browsers fire BOTH ``pointerdown`` AND ``mousedown`` for the + // same user click (pointer events first, then a follow-up mouse event + // for backwards compat). Registering the SAME listener for both fires + // it twice per real click. ``HTML5Implementation.onMouseDown`` / + // ``onMouseUp`` try to dedupe via a stateful ``mouseDown`` flag + // (``shouldIgnoreMousePress`` + ``!isMouseDown()`` early-returns), but + // the dedup gets out of sync — ``mouseDown`` ends up cleared by one + // event-pair half before the matching opposite half can run, so on + // the JS port a Dialog OK click can land on a press whose release + // gets dropped (or vice-versa). Net effect: the modal Dialog never + // disposes, ``invokeAndBlock`` blocks the EDT forever, the UI freezes + // — see PR #4795 dialog-freeze repro. + // + // Fix: register ONLY pointer events. Every browser this port supports + // (Chrome 55+, Edge, Firefox 59+, Safari 13+) ships pointer events; + // they cover mouse, touch, and pen input in one event family. The + // legacy ``mousedown`` / ``mouseup`` registrations are redundant + // and were the cause of the dedup race. if (mouseDownEnabled) { - registrar.add("mousedown", mouseDown, true); registrar.add("pointerdown", mouseDown, true); } registrar.add("hittest", hitTest, true); if (mouseUpEnabled) { - registrar.add("mouseup", mouseUp, true); registrar.add("pointerup", mouseUp, true); - registrar.add("mouseout", mouseUp, true); - registrar.add("pointerout", mouseUp, true); + // ``pointercancel`` is the pointer-events equivalent of + // ``mouseout`` for the click-aborted case (e.g. browser takes + // focus elsewhere mid-drag); keep that side-channel so a stuck + // ``mouseDown`` flag can still recover. + registrar.add("pointercancel", mouseUp, true); } if (touchStartEnabled) { registrar.add("touchstart", touchStart, true); diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/JavaScriptPortBootstrap.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/JavaScriptPortBootstrap.java index 6447ff448d..fcd3d3adf4 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/JavaScriptPortBootstrap.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/JavaScriptPortBootstrap.java @@ -38,7 +38,16 @@ public static Lifecycle createLifecycle(String className) { @JSBody(params = {}, script = "window.cn1Started = true;") private static native void setStarted(); - @JSBody(params = {"url"}, script = "var l = window.location; var base=l.protocol+'//'+l.hostname+(l.port?':':'')+l.port; return url.indexOf(base)===0;") + // The @JSBody body runs against the raw worker-side argument. In the + // ParparVM JS port a Java ``String`` arrives as a wrapped object + // ({__class:"java_lang_String", cn1_..._value: char[]}), not a native + // JS string — calling ``url.indexOf`` directly throws + // ``TypeError: url.indexOf is not a function`` and bubbles up through + // ``proxifyUrl`` → ``ImplementationFactory.proxifyURL`` whenever the + // app loads any image off the theme. Coerce to a native string up + // front (mirrors the pattern already in place for the + // ``measureAscent`` / ``measureDescent`` @JSBody helpers). + @JSBody(params = {"url"}, script = "var s = String(url == null ? '' : url); var l = window.location; var base=l.protocol+'//'+l.hostname+(l.port?':':'')+l.port; return s.indexOf(base)===0;") private static native boolean urlIsSameDomain(String url); public static String proxifyUrl(Display display, String url) { diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/NetworkConnection.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/NetworkConnection.java index cbc7fc582d..4b8aaab01b 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/NetworkConnection.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/NetworkConnection.java @@ -73,7 +73,16 @@ private void open(){ if (!isOpen){ isOpen = true; req.open(httpMethod, url, false); - req.overrideMimeType("text/plain; charset=x-user-defined"); + // ``responseType = "arraybuffer"`` lets the browser hand back + // a typed-array view of the bytes directly. The previous + // ``overrideMimeType("text/plain; charset=x-user-defined")`` + // path forced ``toResponseBytes`` to fall through to a + // per-character ``out.set(i, ...)`` loop -- one JSO bridge + // call per response byte, dominating any non-trivial HTTP + // response on the worker. ``toResponseBytes`` already takes + // the fast arraybuffer path when ``getResponse()`` is set; + // this just makes that branch the actual hot path. + req.setResponseType("arraybuffer"); } } diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/ParparVMBootstrap.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/ParparVMBootstrap.java index 76eb8ed8d7..3e6bece7b4 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/ParparVMBootstrap.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/ParparVMBootstrap.java @@ -31,10 +31,34 @@ public static void bootstrap(Lifecycle lifecycle) { bootstrap.run(); } + // ``window.cn1Initialized = true`` lands on the worker's global + // (window === self inside the worker), but the headless test + // harness and every other main-thread consumer reads its own + // ``window.cn1Initialized``. The bridge (browser_bridge.js) + // already flips its main-thread copy when ``startParparVmApp`` + // runs, so the worker side is best-effort — the real signal + // travels through the message-passing channel instead. @JSBody(params = {}, script = "window.cn1Initialized = true;") private static native void setInitialized(); - @JSBody(params = {}, script = "window.cn1Started = true;") + // For ``cn1Started`` we need the same main-thread signal but + // there's no ``startParparVmApp``-style hook on this side. The + // worker emits a ``{type: 'lifecycle', phase: 'started'}`` VM + // message at the same time so ``browser_bridge.js`` can flip + // its own ``cn1Started``. Fall back gracefully when neither + // ``parentPort`` (Node worker_threads) nor ``self.postMessage`` + // (browser Worker) is available — that path applies to direct + // in-page invocations from the JavaScript-port simulator. + @JSBody(params = {}, script = "" + + "window.cn1Started = true;" + + "var __cn1LifecycleMsg = {type: 'lifecycle', phase: 'started'};" + + "if (typeof parentPort !== 'undefined' && parentPort && typeof parentPort.postMessage === 'function') {" + + " parentPort.postMessage(__cn1LifecycleMsg);" + + "} else if (typeof self !== 'undefined' && self !== this && typeof self.postMessage === 'function') {" + + " self.postMessage(__cn1LifecycleMsg);" + + "} else if (typeof postMessage === 'function') {" + + " postMessage(__cn1LifecycleMsg);" + + "}") private static native void setStarted(); @Override diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/graphics/ClipRect.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/graphics/ClipRect.java index 6d1e86ae61..80e28c527d 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/graphics/ClipRect.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/graphics/ClipRect.java @@ -40,11 +40,24 @@ public static void resetClip(CanvasRenderingContext2D context, ClipState clipSta public void execute(CanvasRenderingContext2D context) { if (clipState.isSet()){ context.restore(); + // Canvas2D save/restore captures and restores the FULL state, + // including the transform. The prior clip op may have been a + // ClipShape whose save() recorded a non-identity transform (the + // shape's coord space, e.g. a rotation); restore() would silently + // revert the canvas transform to that. ClipRect is only ever + // emitted by HTML5Graphics under an identity Java-side transform + // (the non-identity path is routed through clipShape() → the + // ClipShape op), so resetting the canvas to identity here + // restores the canvas-tracks-Java invariant and stops the + // rotated/translated transform from leaking into every draw op + // that follows -- without paying for a getTransform()/setTransform + // pair per clip in the slide-transition rendering hot path. + context.setTransform(1, 0, 0, 1, 0, 0); } clipState.set(true); context.save(); context.beginPath(); - + context.rect(x, y, w, h); context.clip(); } diff --git a/Ports/JavaScriptPort/src/main/webapp/js/fontmetrics.js b/Ports/JavaScriptPort/src/main/webapp/js/fontmetrics.js index 91f318edf9..54df8ac77c 100755 --- a/Ports/JavaScriptPort/src/main/webapp/js/fontmetrics.js +++ b/Ports/JavaScriptPort/src/main/webapp/js/fontmetrics.js @@ -2298,7 +2298,10 @@ window.virtualKeyboardDetector = ( function( window, undefined ) { try { audio.setAttribute('data-cn1-unlocked', 'true'); - console.log('Unlocking audio ', audio); + // (Removed always-on ``console.log('Unlocking audio', audio)`` + // -- fires once per