From d2e9b3b569802ac1993bc5bd605f2812e657dfe5 Mon Sep 17 00:00:00 2001
From: Albrecht Degering
Date: Mon, 27 Jul 2026 13:05:02 +0200
Subject: [PATCH] fix: retain completed results during live editing
---
CHANGELOG.md | 10 +
README.md | 7 +-
SOURCE.md | 10 +-
docs/EXTRACTION_MODEL.md | 2 +-
docs/FLAVOUR_SUPPORT.md | 2 +-
docs/MINIMIZATION.md | 2 +-
docs/PORTAL_REQUIREMENTS.md | 4 +-
docs/REFERENCE_IMPLEMENTATIONS.md | 4 +-
docs/RELEASE.md | 9 +-
package-lock.json | 4 +-
package.json | 2 +-
public/toolbox-app.json | 2 +-
scripts/package-release.mjs | 2 +-
src/components/ExplanationTree.tsx | 16 +-
.../ReplacementPanel.completeness.test.tsx | 18 +
src/components/ReplacementPanel.tsx | 54 ++-
src/components/Workbench.test.tsx | 105 +++++-
src/components/Workbench.tsx | 346 ++++++++++++++----
src/styles.css | 18 +
src/toolbox/manifest.source.json | 2 +-
src/version.ts | 2 +-
tests/browser/workbench.spec.ts | 72 +++-
22 files changed, 576 insertions(+), 117 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fefb158..2bbe912 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,16 @@
## Unreleased
+## 0.4.1 — 2026-07-27
+
+- Keep the last completed syntax and execution frame visible while edited
+ inputs are parsed and executed, then replace it atomically with the newest
+ revision instead of rendering transient empty panels.
+- Bind every retained result to its exact flavour, options, pattern, flags,
+ subject, replacement, mode and syntax snapshots. Mark it as stale, disable
+ execution until current syntax is ready, and suppress stale editor ranges
+ and cross-panel selection.
+
## 0.4.0 — 2026-07-27
- Expand the workbench from four to twelve selectable execution profiles:
diff --git a/README.md b/README.md
index acb2b36..b7e20cd 100644
--- a/README.md
+++ b/README.md
@@ -8,9 +8,9 @@ Patterns, replacement templates, test text and project files stay in the
browser. There is no account, backend, upload, remote execution, telemetry,
runtime CDN, remote font, or cloud-save service.
-## Current release — 0.4.0
+## Current release — 0.4.1
-The v0.4.0 release contains twelve selectable, executable profiles:
+The v0.4.1 release contains twelve selectable, executable profiles:
- **JavaScript (ECMAScript)** — the current browser's native `RegExp`, with
ECMAScript 2025 syntax supplied by regexpp 4.12.2;
@@ -99,6 +99,9 @@ relabeled Scala engine.
- Parsing and execution run in separate workers.
- Actual execution can be terminated by killing its worker.
- The worker is recreated after timeout, crash or cancellation.
+- Live editing retains the last completed, input-bound syntax and result frame
+ until the newest revision finishes. Stale frames are labelled and cannot
+ enable Run, decorate current editors, or drive current-input selections.
- ECMAScript capture ranges use the engine's `d` indices result; every other
profile returns its native range records through a bounded fixed bridge.
- Named, numbered, optional, empty and repeated-final captures are represented.
diff --git a/SOURCE.md b/SOURCE.md
index 0899ba4..7d3dece 100644
--- a/SOURCE.md
+++ b/SOURCE.md
@@ -1,10 +1,10 @@
# Source identity
- Project: Regex Tools
-- Release version: `0.4.0`
-- Release tag: `v0.4.0`
+- Release version: `0.4.1`
+- Release tag: `v0.4.1`
- Repository:
-- Previous release tag: `v0.3.0`
+- Previous release tag: `v0.4.0`
- Licence: GPL-3.0-or-later
- Copyright: © 2026 Albrecht Degering
@@ -82,7 +82,7 @@ metadata, closed file sets, checksums and exact licence/notice material are
installed under `public/engines/` and included in the application source and
build.
-The v0.4.0 release includes native browser ECMAScript, standalone PCRE2 10.47,
+The v0.4.1 release includes native browser ECMAScript, standalone PCRE2 10.47,
PHP 8.5.8 `preg` with PCRE2 10.44, legacy Perl 5.28.1 via WebPerl 0.09-beta,
CPython 3.14.2 `re`, CRuby 4.0.0 `Regexp`, TeaVM 0.15.0 `java.util.regex`,
Emscripten 6.0.4 libc++ `std::wregex`, Go 1.26.5 `regexp`, Rust `regex` 1.13.1,
@@ -91,4 +91,4 @@ TeaVM-backed Scala/JVM compatibility profile.
Earlier releases remain available under their historical tags and artifact
coordinates. A future public artifact must bump package, manifest, tag and
-source identity together; it must not reuse `v0.4.0`.
+source identity together; it must not reuse `v0.4.1`.
diff --git a/docs/EXTRACTION_MODEL.md b/docs/EXTRACTION_MODEL.md
index 55406c0..fbe6e9a 100644
--- a/docs/EXTRACTION_MODEL.md
+++ b/docs/EXTRACTION_MODEL.md
@@ -18,7 +18,7 @@ budget. Consumers must inspect the preview status rather than treating a
preview as complete. In particular, list export is disabled if its template
would consume an incomplete value.
-Repeated captures expose only the final retained value in all twelve v0.4.0
+Repeated captures expose only the final retained value in all twelve v0.4.1
profiles. This includes .NET even though its native API can retain capture
history: the current adapter deliberately normalizes only the final record and
reports `captureHistory: false`. The UI labels the limitation and does not
diff --git a/docs/FLAVOUR_SUPPORT.md b/docs/FLAVOUR_SUPPORT.md
index feeea00..f482af9 100644
--- a/docs/FLAVOUR_SUPPORT.md
+++ b/docs/FLAVOUR_SUPPORT.md
@@ -1,6 +1,6 @@
# Flavour support
-Version 0.4.0 exposes twelve executable profiles. “Partial lexical provider”
+Version 0.4.1 exposes twelve executable profiles. “Partial lexical provider”
means that Regex Tools recognizes common structure and replacement tokens for
explanation only; the named runtime compiler and replacement operation remain
authoritative.
diff --git a/docs/MINIMIZATION.md b/docs/MINIMIZATION.md
index 8f19c4e..313d335 100644
--- a/docs/MINIMIZATION.md
+++ b/docs/MINIMIZATION.md
@@ -11,7 +11,7 @@ selected real ECMAScript or PCRE2 worker. It can preserve:
authoritative, untruncated output.
PHP, Perl, Python, Ruby, Java, C++, Go, Rust, .NET and Scala compatibility are
-not supported minimization targets in v0.4.0. The workspace disables reduction
+not supported minimization targets in v0.4.1. The workspace disables reduction
for those active profiles; it does not substitute ECMAScript, PCRE2 or a
lexical provider as an execution oracle.
diff --git a/docs/PORTAL_REQUIREMENTS.md b/docs/PORTAL_REQUIREMENTS.md
index 90ceb0f..070e747 100644
--- a/docs/PORTAL_REQUIREMENTS.md
+++ b/docs/PORTAL_REQUIREMENTS.md
@@ -38,9 +38,9 @@ should test both a direct root mount and a nested mount such as
`/apps/regex/`, including cold loading of all twelve profiles.
The Portal must consume the immutable release ZIP, verify its SHA-256, verify
-manifest ID `de.add-ideas.regex-tools` and version `0.4.0`, and mount it at a
+manifest ID `de.add-ideas.regex-tools` and version `0.4.1`, and mount it at a
relative target such as `apps/regex/`.
-The package, manifest, tag, artifact URL and checksum must all use the v0.4.0
+The package, manifest, tag, artifact URL and checksum must all use the v0.4.1
identity. Historical artifact coordinates remain immutable and must not be
overwritten or reused.
diff --git a/docs/REFERENCE_IMPLEMENTATIONS.md b/docs/REFERENCE_IMPLEMENTATIONS.md
index 4b2d726..0dffa40 100644
--- a/docs/REFERENCE_IMPLEMENTATIONS.md
+++ b/docs/REFERENCE_IMPLEMENTATIONS.md
@@ -4,7 +4,7 @@ Inspection date: 2026-07-27.
| Reference | Exact revision/version | Licence | Use and copying status |
| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- |
-| Regex Tools | Release identity `v0.4.0` | GPL-3.0-or-later | Application source written for this project. |
+| Regex Tools | Release identity `v0.4.1` | GPL-3.0-or-later | Application source written for this project. |
| Toolbox SDK | `53c40a61ba1581246f65773fcbb1c1cfd31ac98e` / 0.2.2 | Apache-2.0 | Contract, shell and release conventions; package APIs used, no source adapted. |
| Toolbox Portal | `9347554324299c8ec9dfaec2c9730907736f62c3` / aggregate v0.8.0 / package 0.2.7 | AGPL-3.0-only | Assembly/lock conventions inspected; no source copied into the app. |
| regexpp | 4.12.2; pinned npm integrity in `package-lock.json` | MIT | ECMAScript 2025 syntax provider; normalized in the syntax worker. |
@@ -30,6 +30,6 @@ Inspection date: 2026-07-27.
| RegexLib | Website inspected | Redistribution licence not established | No fixture copied. |
| RGXP.RU | Website inspected | Redistribution licence not established | No fixture copied. |
-All v0.1.0 through v0.4.0 conformance cases are project-authored. See
+All v0.1.0 through v0.4.1 conformance cases are project-authored. See
`tests/fixtures/README.md`. Runtime pack metadata and adjacent licence/notice
files are authoritative for the exact binary distribution.
diff --git a/docs/RELEASE.md b/docs/RELEASE.md
index 90f2990..34816e1 100644
--- a/docs/RELEASE.md
+++ b/docs/RELEASE.md
@@ -18,8 +18,8 @@ on publication failure, replaces an existing exact version only with
`--force`, and produces:
```text
-release/regex-tools-0.4.0.zip
-release/regex-tools-0.4.0.zip.sha256
+release/regex-tools-0.4.1.zip
+release/regex-tools-0.4.1.zip.sha256
```
The release scan checks ASCII/UTF-8, UTF-16LE and UTF-16BE content in text,
@@ -43,6 +43,5 @@ anchored base-runtime legal files. The C++ gate requires its exact traced
component/source manifest and six independently pinned legal files. The release
packager names every one of those files explicitly.
-The existing v0.1.0, v0.2.0 and v0.3.0 ZIPs and matching sidecars are
-historical immutable artifacts. Creating v0.4.0 must not replace, rename or
-remove them.
+The existing v0.1.0 through v0.4.0 ZIPs and matching sidecars are historical
+immutable artifacts. Creating v0.4.1 must not replace, rename or remove them.
diff --git a/package-lock.json b/package-lock.json
index 5eb7922..f595226 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "regex-tools",
- "version": "0.4.0",
+ "version": "0.4.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "regex-tools",
- "version": "0.4.0",
+ "version": "0.4.1",
"license": "GPL-3.0-or-later",
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.2",
diff --git a/package.json b/package.json
index c938fe5..b4c270e 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "regex-tools",
- "version": "0.4.0",
+ "version": "0.4.1",
"description": "Develop, explain, test and apply regular expressions locally in the browser.",
"license": "GPL-3.0-or-later",
"author": "Albrecht Degering",
diff --git a/public/toolbox-app.json b/public/toolbox-app.json
index ca8642e..774feef 100644
--- a/public/toolbox-app.json
+++ b/public/toolbox-app.json
@@ -3,7 +3,7 @@
"schemaVersion": 1,
"id": "de.add-ideas.regex-tools",
"name": "Regex Tools",
- "version": "0.4.0",
+ "version": "0.4.1",
"description": "Develop, explain, test and apply regular expressions locally in the browser.",
"entry": "./",
"icon": "./favicon.svg",
diff --git a/scripts/package-release.mjs b/scripts/package-release.mjs
index 085d1d6..65f5b01 100644
--- a/scripts/package-release.mjs
+++ b/scripts/package-release.mjs
@@ -19,7 +19,7 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const zipEpoch = new Date(1980, 0, 1, 0, 0, 0);
const gplVersion3TextSha256 =
"fb981668c18a279e285fc4d83fba1e836cc84dd4daa73c9697d3cfd2d8aca6e0";
-const expectedApplicationVersion = "0.4.0";
+const expectedApplicationVersion = "0.4.1";
const requiredRootFiles = [
"LICENSE",
"README.md",
diff --git a/src/components/ExplanationTree.tsx b/src/components/ExplanationTree.tsx
index 8220ba3..03f5b8c 100644
--- a/src/components/ExplanationTree.tsx
+++ b/src/components/ExplanationTree.tsx
@@ -8,6 +8,7 @@ export interface ExplanationTreeProps {
readonly root?: NormalizedRegexNode;
readonly selectedId?: string;
readonly flags?: readonly string[];
+ readonly stale?: boolean;
readonly onSelect: (node: NormalizedRegexNode) => void;
}
@@ -165,6 +166,7 @@ export function ExplanationTree({
root,
selectedId,
flags = [],
+ stale = false,
onSelect,
}: ExplanationTreeProps) {
const totalNodes = useMemo(() => (root ? countNodes(root) : 0), [root]);
@@ -191,8 +193,18 @@ export function ExplanationTree({
Pattern structure
Explanation tree
-
- Deterministic · syntax provider
+
+ {stale
+ ? "Stale snapshot · checking current pattern"
+ : "Deterministic · syntax provider"}
{totalNodes > renderedNodes ? (
diff --git a/src/components/ReplacementPanel.completeness.test.tsx b/src/components/ReplacementPanel.completeness.test.tsx
index 6e030aa..e8ab3b0 100644
--- a/src/components/ReplacementPanel.completeness.test.tsx
+++ b/src/components/ReplacementPanel.completeness.test.tsx
@@ -74,6 +74,24 @@ describe("ReplacementPanel completeness", () => {
},
);
+ it("labels retained tokens with the flavour that parsed the snapshot", () => {
+ render(
+ undefined}
+ onSelectToken={() => undefined}
+ onSelectContribution={() => undefined}
+ />,
+ );
+
+ expect(screen.getByText("Python re template syntax")).toBeInTheDocument();
+ expect(
+ screen.getByText("Java Pattern derived token explanation"),
+ ).toBeInTheDocument();
+ });
+
it("labels a match-limited replacement as partial", () => {
renderResult({
execution: { ...execution, truncated: true },
diff --git a/src/components/ReplacementPanel.tsx b/src/components/ReplacementPanel.tsx
index 244576e..b6d7646 100644
--- a/src/components/ReplacementPanel.tsx
+++ b/src/components/ReplacementPanel.tsx
@@ -71,7 +71,11 @@ export function ReplacementPanel({
replacement,
onReplacementChange,
syntax,
+ presentedSyntax,
+ syntaxStale = false,
+ presentedFlavour,
result,
+ resultFlavour,
preview,
selectedToken,
selectedContributionId,
@@ -83,7 +87,11 @@ export function ReplacementPanel({
readonly replacement: string;
readonly onReplacementChange: (value: string) => void;
readonly syntax?: ReplacementSyntaxResult;
+ readonly presentedSyntax?: ReplacementSyntaxResult;
+ readonly syntaxStale?: boolean;
+ readonly presentedFlavour?: RegexFlavourId;
readonly result?: RegexReplacementResult;
+ readonly resultFlavour?: RegexFlavourId;
readonly preview?: ReplacementPreview;
readonly selectedToken?: ReplacementToken;
readonly selectedContributionId?: string;
@@ -96,8 +104,16 @@ export function ReplacementPanel({
const activeFlavour =
flavour ?? result?.execution.engine.flavour ?? "ecmascript";
const engineLabel = TEMPLATE_FLAVOUR_LABELS[activeFlavour];
- const retainedTokens = syntax?.tokens ?? [];
- const editorTokens = retainedTokens.slice(
+ const presentedEngineLabel =
+ TEMPLATE_FLAVOUR_LABELS[presentedFlavour ?? activeFlavour];
+ const resultEngineLabel =
+ TEMPLATE_FLAVOUR_LABELS[
+ resultFlavour ?? result?.execution.engine.flavour ?? activeFlavour
+ ];
+ const retainedSyntax = presentedSyntax ?? syntax;
+ const retainedTokens = retainedSyntax?.tokens ?? [];
+ const currentTokens = syntax?.tokens ?? [];
+ const editorTokens = currentTokens.slice(
0,
TEMPLATE_PRESENTATION_LIMITS.replacementEditorMarks,
);
@@ -129,7 +145,7 @@ export function ReplacementPanel({
maximumLengthUtf16={
DEFAULT_REGEX_LIMITS.maximumReplacementTemplateUtf16
}
- selectedRange={selectedToken?.range}
+ selectedRange={syntaxStale ? undefined : selectedToken?.range}
marks={[
...editorTokens.map((token) => ({
id: token.id,
@@ -137,7 +153,7 @@ export function ReplacementPanel({
className: `cm-replacement-token cm-replacement-${token.kind}`,
label: token.explanation,
})),
- ...(selectedToken
+ ...(!syntaxStale && selectedToken
? [
{
id: "selected-replacement-token",
@@ -163,7 +179,7 @@ export function ReplacementPanel({
unit limit.
) : null}
- {retainedTokens.length >
+ {currentTokens.length >
TEMPLATE_PRESENTATION_LIMITS.replacementEditorMarks ? (
Editor decorations limited. Showing the first{" "}
{TEMPLATE_PRESENTATION_LIMITS.replacementEditorMarks.toLocaleString()}{" "}
- of {retainedTokens.length.toLocaleString()} complete replacement
+ of {currentTokens.length.toLocaleString()} complete replacement
tokens.
) : null}
@@ -182,9 +198,24 @@ export function ReplacementPanel({
>
{renderedTokens.length > 0 ? (
<>
@@ -222,12 +253,12 @@ export function ReplacementPanel({
>
) : (
- {syntax?.accepted === false
+ {retainedSyntax?.accepted === false
? "Token parsing is unavailable until the template is within the input limit."
: "Enter a replacement template."}
)}
- {syntax?.diagnosticsTruncated ? (
+ {retainedSyntax?.diagnosticsTruncated ? (
@@ -252,7 +284,7 @@ export function ReplacementPanel({