From 28d7c4db066b4243afd202d38b4a9e75555141f8 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 29 Oct 2026 22:51:36 +0000 Subject: [PATCH] feat(site): keep the address bar a link to the model being edited The fragment follows the editor text and the selected index, written with history.replaceState after the page has been still for a moment, so editing adds no history entries and fires no hashchange. A page in the state it opened with keeps the address it opened at, including a gist's, and returns to it when changed back. A link past the longest address a browser is known to accept is not written: the fragment is cleared and the page says to share a gist. --- README.md | 2 +- e2e/sharing.e2e.ts | 149 ++++++++++++++++++++++++++++++++++ site/index.html | 1 + site/src/address.ts | 46 +++++++++++ site/src/address.unit.test.ts | 78 ++++++++++++++++++ site/src/main.ts | 53 ++++++++++-- 6 files changed, 321 insertions(+), 8 deletions(-) create mode 100644 site/src/address.ts create mode 100644 site/src/address.unit.test.ts diff --git a/README.md b/README.md index 7cb87f5..f68eedf 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Constraints are evaluated three-valued on partial assignments, answering valid, ## Explorer -An [explorer](https://exadev.github.io/combinatrix/) runs the library in the browser: build a model with a form (dimensions and their values, and rules as nested conditions, with a control for every kind of node in trilean's schema) or edit it as JSON in the Advanced view, where each is the same model as the other, then count its combinations, list them up to a limit, find dead values, explain why a combination is excluded, build a covering array and check it, and move between a combination and its index. The engine runs in a Web Worker, so the page stays responsive and Cancel stops a long operation. Copy link puts the model, compressed, in the link's fragment, which a browser never sends to a server, so a link is self-contained; `?gist=` loads the first `.json` file of a public gist for a model too large for a link. Models can be saved in the browser and imported or exported as `.json` files. Its source is in `site/` and is not part of the published package. +An [explorer](https://exadev.github.io/combinatrix/) runs the library in the browser: build a model with a form (dimensions and their values, and rules as nested conditions, with a control for every kind of node in trilean's schema) or edit it as JSON in the Advanced view, where each is the same model as the other, then count its combinations, list them up to a limit, find dead values, explain why a combination is excluded, build a covering array and check it, and move between a combination and its index. The engine runs in a Web Worker, so the page stays responsive and Cancel stops a long operation. Copy link puts the model, compressed, in the link's fragment, which a browser never sends to a server, so a link is self-contained, and the address bar holds that same link as the model or the selected index changes (a model too large for an address goes by gist instead); `?gist=` loads the first `.json` file of a public gist for a model too large for a link. Models can be saved in the browser and imported or exported as `.json` files. Its source is in `site/` and is not part of the published package. ## Examples diff --git a/e2e/sharing.e2e.ts b/e2e/sharing.e2e.ts index ecfde95..d706afa 100644 --- a/e2e/sharing.e2e.ts +++ b/e2e/sharing.e2e.ts @@ -5,6 +5,8 @@ import { join } from 'node:path'; import { expect, test } from '@playwright/test'; import type { Page } from '@playwright/test'; +import { ADDRESS_DEBOUNCE_MS, MAX_ADDRESS_LENGTH } from '../site/src/address'; +import { decodeState, encodedStateOf } from '../site/src/fragment'; import type { JsonObject } from '../site/src/json'; import { Explorer, RESULT_TIMEOUT_MS, STARTING_EXAMPLE } from './explorer'; @@ -19,6 +21,44 @@ const GIST_MODEL: JsonObject = { dimensions: [{ kind: 'choice', name: 'from-gist const HTTP_OK = 200; const HTTP_NOT_FOUND = 404; +/** Characters in a model too large for the address bar, as multiples of the longest address the page writes. Deflate shrinks random base-36 text to about five eighths of its length and base64url grows that by a third, so a text this many times the limit makes a link comfortably longer than it. */ +const OVERSIZE_FACTOR = 1.5; + +/** The multiplier and increment of a linear congruential generator, so the large model is the same on every run. */ +const LCG_MULTIPLIER = 1_103_515_245; +const LCG_INCREMENT = 12_345; + +/** The radix that writes a number in the fewest characters, and the characters one number is written in. */ +const BASE_36 = 36; +const DIGITS_PER_NUMBER = 6; + +/** A text of at least `length` characters that deflate cannot shrink much, the same on every run. */ +function incompressibleText(length: number): string { + let seed = 1; + let text = ''; + while (text.length < length) { + seed = (Math.imul(seed, LCG_MULTIPLIER) + LCG_INCREMENT) >>> 0; + text += seed.toString(BASE_36).padStart(DIGITS_PER_NUMBER, '0'); + } + + return text; +} + +/** The index the state in a fragment holds, or `undefined` when the fragment holds no state or no index. */ +async function decodedIndexOf(hash: string): Promise { + const encoded = encodedStateOf(hash); + + return encoded === undefined ? undefined : (await decodeState(encoded)).index; +} + +/** What the page logs when its fragment changes by anything but its own address writes. */ +const HASH_CHANGE_MESSAGE = 'hashchange fired'; + +/** The fragment of the page's address now. */ +async function hashOf(page: Page): Promise { + return page.evaluate((): string => location.hash); +} + /** Answers GitHub's gist API from the test, so no request leaves the machine. */ async function serveGist(page: Page, status: number, body: unknown): Promise { const requested: string[] = []; @@ -94,6 +134,115 @@ test.describe('share links', () => { }); }); +test.describe('the address bar', () => { + test('stays as it was opened until the model is changed, and returns to it when the model is changed back', async ({ page }) => { + const explorer = await Explorer.open(page); + const opened = page.url(); + const text = await (await explorer.advanced()).inputValue(); + await page.waitForTimeout(ADDRESS_DEBOUNCE_MS * 2); + + expect(page.url()).toBe(opened); + await explorer.setJson({ dimensions: [{ kind: 'choice', name: 'changed', values: ['yes'] }] }); + await expect.poll(async () => hashOf(page), { timeout: RESULT_TIMEOUT_MS }).toMatch(/^#s=/); + await explorer.setText(text); + + await expect.poll(() => page.url(), { timeout: RESULT_TIMEOUT_MS }).toBe(opened); + }); + + test('follows an edit made in the form, without adding history entries or firing hashchange', async ({ page }) => { + const explorer = await Explorer.open(page); + await explorer.loadExample(STARTING_EXAMPLE); + const hashChanges: string[] = []; + page.on('console', (message) => { + if (message.text() === HASH_CHANGE_MESSAGE) hashChanges.push(message.text()); + }); + await page.evaluate((message) => { + window.addEventListener('hashchange', () => { + console.log(message); + }); + }, HASH_CHANGE_MESSAGE); + const history = await page.evaluate(() => window.history.length); + await explorer.field('dimensions.2.values:add').click(); + await explorer.field('dimensions.2.values.3:value').fill('wasmtime'); + await explorer.ready(); + await expect.poll(async () => hashOf(page), { timeout: RESULT_TIMEOUT_MS }).toMatch(/^#s=/); + + expect(await page.evaluate(() => window.history.length)).toBe(history); + expect(hashChanges).toEqual([]); + }); + + test('is the link Copy link makes, so reloading it, or opening it elsewhere, shows the same model and combination', async ({ page, context }) => { + const explorer = await Explorer.open(page); + await explorer.loadExample(STARTING_EXAMPLE); + await explorer.field('dimensions.2.values:add').click(); + await explorer.field('dimensions.2.values.3:value').fill('wasmtime'); + await explorer.ready(); + await explorer.setNumber('index', SHARED_INDEX); + await expect.poll(async () => decodedIndexOf(await hashOf(page)), { timeout: RESULT_TIMEOUT_MS }).toBe(SHARED_INDEX); + const model = await explorer.json(); + const link = page.url(); + await explorer.id('copy-link').click(); + await expect(explorer.id('link-status')).toContainText('Link copied', { timeout: RESULT_TIMEOUT_MS }); + + expect(await explorer.id('link').inputValue()).toBe(link); + await page.reload(); + await explorer.ready(); + expect(await explorer.json()).toEqual(model); + await expect(explorer.id('index')).toHaveValue(String(SHARED_INDEX)); + const fresh = await context.newPage(); + await fresh.goto(link); + const opened = new Explorer(fresh); + await opened.ready(); + + expect(await opened.json()).toEqual(model); + }); + + test('keeps text that is not yet valid JSON, so reloading does not lose it', async ({ page }) => { + const explorer = await Explorer.open(page); + await explorer.setText('{ not json yet'); + await expect.poll(async () => hashOf(page), { timeout: RESULT_TIMEOUT_MS }).toMatch(/^#s=/); + await page.reload(); + + await expect(explorer.id('model')).toHaveValue('{ not json yet', { timeout: RESULT_TIMEOUT_MS }); + }); + + test('replaces the gist of the address with the edited model, which a reload then shows instead of the gist', async ({ page }) => { + const requested = await serveGist(page, HTTP_OK, { files: { 'model.json': { content: JSON.stringify(GIST_MODEL) } } }); + await page.goto(`?gist=${GIST_ID}`); + const explorer = new Explorer(page); + await explorer.ready(); + await page.waitForTimeout(ADDRESS_DEBOUNCE_MS * 2); + + expect(new URL(page.url()).search).toBe(`?gist=${GIST_ID}`); + await explorer.field('dimensions.0.values:add').click(); + await explorer.field('dimensions.0.values.3:value').fill('four'); + await explorer.ready(); + await expect.poll(async () => hashOf(page), { timeout: RESULT_TIMEOUT_MS }).toMatch(/^#s=/); + const edited = await explorer.json(); + await page.reload(); + await explorer.ready(); + + expect(new URL(page.url()).search).toBe(''); + expect(await explorer.json()).toEqual(edited); + expect(requested).toEqual([`https://api.github.com/gists/${GIST_ID}`]); + }); + + test('stops following a model too large for an address, says so, and follows again once it is small', async ({ page }) => { + const explorer = await Explorer.open(page); + await explorer.setJson({ dimensions: [{ kind: 'choice', name: 'small', values: ['yes'] }] }); + await expect.poll(async () => hashOf(page), { timeout: RESULT_TIMEOUT_MS }).toMatch(/^#s=/); + const large = JSON.stringify({ dimensions: [{ kind: 'choice', name: incompressibleText(Math.ceil(MAX_ADDRESS_LENGTH * OVERSIZE_FACTOR)), values: ['yes'] }] }); + await explorer.setJson(large); + + await expect(explorer.id('address-status')).toContainText('too large', { timeout: RESULT_TIMEOUT_MS }); + expect(await hashOf(page)).toBe(''); + await explorer.setJson({ dimensions: [{ kind: 'choice', name: 'small again', values: ['yes'] }] }); + + await expect(explorer.id('address-status')).toHaveText('', { timeout: RESULT_TIMEOUT_MS }); + await expect.poll(async () => hashOf(page), { timeout: RESULT_TIMEOUT_MS }).toMatch(/^#s=/); + }); +}); + test.describe('gists', () => { test('loads the first .json file of a public gist by id, asking only GitHub for it', async ({ page }) => { const requested = await serveGist(page, HTTP_OK, { files: { 'notes.md': { content: 'ignore me' }, 'model.json': { content: JSON.stringify(GIST_MODEL) } } }); diff --git a/site/index.html b/site/index.html index 5f03eb3..9e0cfff 100644 --- a/site/index.html +++ b/site/index.html @@ -49,6 +49,7 @@

Model

+

diff --git a/site/src/address.ts b/site/src/address.ts new file mode 100644 index 0000000..55283f1 --- /dev/null +++ b/site/src/address.ts @@ -0,0 +1,46 @@ +import type { SharedState } from "./fragment"; +import { shareUrl } from "./fragment"; + +/** + * The longest address the page writes into the address bar, in characters. Firefox parses a standard URL up to one mebibyte (Chromium accepts two across its process boundary), so a longer link may be refused by a browser the page cannot ask; a model that large travels as a gist. + */ +export const MAX_ADDRESS_LENGTH = 1_048_576; + +/** WebKit throws a `SecurityError` from `history.replaceState` past this many calls in one window of time. */ +const WEBKIT_REPLACE_STATE_CALLS = 100; + +/** The window of time, in milliseconds, in which WebKit counts `history.replaceState` calls. */ +const WEBKIT_REPLACE_STATE_WINDOW_MS = 30_000; + +/** + * How long after the last change the address is written. Writes are at least this far apart, because a write is made this long after the last change that came before it, so at most `WEBKIT_REPLACE_STATE_CALLS` happen in WebKit's window. + */ +export const ADDRESS_DEBOUNCE_MS = WEBKIT_REPLACE_STATE_WINDOW_MS / WEBKIT_REPLACE_STATE_CALLS; + +/** What the address bar should hold for the page's current state. */ +export type Address = + | { readonly kind: "unchanged"; readonly href: string } + | { readonly kind: "link"; readonly href: string } + | { readonly kind: "too-large"; readonly href: string }; + +/** The state a page opened with, and the address it was opened at. */ +export interface Opened { + readonly href: string; + readonly state: SharedState; +} + +/** + * The address for `state`. A state equal to the one the page opened with keeps the address it opened at, so a page that has not been changed (or has been changed back) shows the address it was given: a clean one, or a gist's. Any other state is a link holding it, as Copy link would make, unless that link is longer than `maxLength`, in which case the address is the page's own, without its query or fragment, because a stale fragment would restore a model the page no longer shows. + */ +export async function addressFor(opened: Opened, current: string, state: SharedState, maxLength = MAX_ADDRESS_LENGTH): Promise
{ + if (state.model === opened.state.model && state.index === opened.state.index) return { kind: "unchanged", href: opened.href }; + + const link = await shareUrl(current, state); + if (link.length <= maxLength) return { kind: "link", href: link }; + + const bare = new URL(current); + bare.search = ""; + bare.hash = ""; + + return { kind: "too-large", href: bare.href }; +} diff --git a/site/src/address.unit.test.ts b/site/src/address.unit.test.ts new file mode 100644 index 0000000..48bf3ce --- /dev/null +++ b/site/src/address.unit.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; + +import { ADDRESS_DEBOUNCE_MS, addressFor } from "./address"; +import { decodeState, encodedStateOf } from "./fragment"; + +const PAGE = "https://example.com/combinatrix/"; +const OPENED = { href: `${PAGE}?gist=0123abcd`, state: { model: '{"dimensions":[]}', index: 0 } }; + +/** A model long enough that its link is clearly longer than the small limit the test sets. */ +const OVERSIZE_CHARACTERS = 1000; + +/** A maximum length that no link in these tests reaches, so the limit is not what they are about. */ +const NO_LIMIT = Number.MAX_SAFE_INTEGER; + +describe("addressFor", () => { + it("keeps the address the page opened at while the state is the one it opened with", async () => { + expect(await addressFor(OPENED, `${PAGE}#s=anything`, { ...OPENED.state })).toEqual({ kind: "unchanged", href: OPENED.href }); + }); + + it("keeps the opened address for a page that opened without an index until an index is chosen", async () => { + const opened = { href: PAGE, state: { model: "{}" } }; + + expect(await addressFor(opened, PAGE, { model: "{}" })).toEqual({ kind: "unchanged", href: PAGE }); + expect((await addressFor(opened, PAGE, { model: "{}", index: 0 })).kind).toBe("link"); + }); + + it("makes a link holding the changed model, with the query of the current address dropped", async () => { + const changed = { model: '{"dimensions":[{"kind":"choice","name":"os","values":["linux"]}]}', index: 3 }; + const address = await addressFor(OPENED, `${PAGE}?gist=0123abcd`, changed, NO_LIMIT); + + expect(address.kind).toBe("link"); + const url = new URL(address.href); + expect(url.search).toBe(""); + const encoded = encodedStateOf(url.hash); + expect(encoded).toBeDefined(); + expect(await decodeState(String(encoded))).toEqual(changed); + }); + + it("makes the same link Copy link would make for the same state", async () => { + const changed = { model: '{"dimensions":[]} ', index: 1 }; + const first = await addressFor(OPENED, PAGE, changed, NO_LIMIT); + const second = await addressFor(OPENED, first.href, changed, NO_LIMIT); + + expect(second.href).toBe(first.href); + }); + + it("falls back to the opened address once a changed state is changed back", async () => { + const changed = { model: "{}", index: 0 }; + const link = await addressFor(OPENED, PAGE, changed, NO_LIMIT); + const back = await addressFor(OPENED, link.href, { ...OPENED.state }, NO_LIMIT); + + expect(back).toEqual({ kind: "unchanged", href: OPENED.href }); + }); + + it("drops the query and fragment, and says so, for a link longer than the limit", async () => { + const changed = { model: "x".repeat(OVERSIZE_CHARACTERS), index: 0 }; + const link = await addressFor(OPENED, PAGE, changed, NO_LIMIT); + const address = await addressFor(OPENED, `${PAGE}?gist=0123abcd#s=stale`, changed, link.href.length - 1); + + expect(address).toEqual({ kind: "too-large", href: PAGE }); + }); + + it("accepts a link exactly as long as the limit", async () => { + const changed = { model: "{}", index: 0 }; + const link = await addressFor(OPENED, PAGE, changed, NO_LIMIT); + + expect((await addressFor(OPENED, PAGE, changed, link.href.length)).kind).toBe("link"); + }); +}); + +describe("ADDRESS_DEBOUNCE_MS", () => { + it("spaces writes so that WebKit's window never holds more than its limit", () => { + const WINDOW_MS = 30_000; + const CALLS = 100; + + expect(Math.floor(WINDOW_MS / ADDRESS_DEBOUNCE_MS)).toBeLessThanOrEqual(CALLS); + }); +}); diff --git a/site/src/main.ts b/site/src/main.ts index 285231c..b8efbc7 100644 --- a/site/src/main.ts +++ b/site/src/main.ts @@ -1,5 +1,7 @@ import type { Value } from "combinatrix"; +import type { Opened } from "./address"; +import { ADDRESS_DEBOUNCE_MS, addressFor } from "./address"; import type { SpawnWorker } from "./client"; import { CancelledError, EngineClient } from "./client"; import type { EditorState, ItemSwitch } from "./editorState"; @@ -51,6 +53,7 @@ const copyLink = byId("copy-link", HTMLButtonElement); const exportButton = byId("export", HTMLButtonElement); const importInput = byId("import", HTMLInputElement); const linkStatus = byId("link-status", HTMLParagraphElement); +const addressStatus = byId("address-status", HTMLParagraphElement); const linkField = byId("link", HTMLInputElement); const saveName = byId("save-name", HTMLInputElement); const saveButton = byId("save", HTMLButtonElement); @@ -102,6 +105,12 @@ let validating = false; let operating = false; let debounce: ReturnType | undefined; +/** The state and address the page opened with, once it has opened: the address bar shows this address whenever the page is in this state, and a link holding the state otherwise. */ +let opened: Opened | undefined; +let addressTimer: ReturnType | undefined; +/** Counts writes of the address, so that one superseded while its link was being made is dropped. */ +let addressRevision = 0; + // Sending /** Runs a request on the engine. `undefined` means the user cancelled it. A worker that dies is reported as a failure like any other. */ @@ -210,6 +219,7 @@ function editorChanged(): void { refresh(); clearTimeout(debounce); debounce = setTimeout(() => void validateEditor(), VALIDATE_DEBOUNCE_MS); + addressChanged(); } // The form and the JSON view @@ -484,10 +494,38 @@ function selectedIndex(): number | undefined { return index !== undefined && index >= 0 ? index : undefined; } -async function copyLinkToClipboard(): Promise { +/** What a link to the page as it is now holds: the editor's text and the selected index. */ +function sharedState(): SharedState { const index = selectedIndex(); - const shared: SharedState = index === undefined ? { model: editor.value } : { model: editor.value, index }; - const link = await shareUrl(window.location.href, shared); + + return index === undefined ? { model: editor.value } : { model: editor.value, index }; +} + +/** The address bar follows the page: it holds a link to the model and index shown, so copying it from the browser is the same as Copy link. */ +function addressChanged(): void { + clearTimeout(addressTimer); + addressTimer = setTimeout(() => void writeAddress(), ADDRESS_DEBOUNCE_MS); +} + +async function writeAddress(): Promise { + if (opened === undefined) return; + + addressRevision += 1; + const revision = addressRevision; + const address = await addressFor(opened, window.location.href, sharedState()); + if (revision !== addressRevision) return; + + window.history.replaceState(window.history.state, "", address.href); + addressStatus.textContent = address.kind === "too-large" ? "This model is too large for the address bar, which has stopped following your edits. Share it as a gist." : ""; +} + +/** Records the page as opened: its address and the state it shows, which the address bar goes back to when the page is changed back. */ +function markOpened(): void { + opened = { href: window.location.href, state: sharedState() }; +} + +async function copyLinkToClipboard(): Promise { + const link = await shareUrl(window.location.href, sharedState()); linkField.value = link; linkField.hidden = false; try { @@ -560,11 +598,10 @@ async function loadFromLocation(): Promise { notice.hidden = true; setModel(restored.model); + if (restored.index !== undefined) indexInput.value = String(restored.index); + markOpened(); await validateEditor(); - if (restored.index !== undefined) { - indexInput.value = String(restored.index); - await atOperation(); - } + if (restored.index !== undefined) await atOperation(); return true; } catch (error) { @@ -610,6 +647,7 @@ function start(): void { byId("covering", HTMLButtonElement).addEventListener("click", () => void coveringOperation()); byId("at", HTMLButtonElement).addEventListener("click", () => void atOperation()); byId("explain", HTMLButtonElement).addEventListener("click", () => void explainOperation()); + indexInput.addEventListener("input", addressChanged); window.addEventListener("hashchange", () => void loadFromLocation()); renderSaved(); @@ -620,6 +658,7 @@ function start(): void { if (example === undefined) throw new Error("unreachable: the starting example is one of the examples"); setModel(exampleText(example)); + markOpened(); }); }