Alter URL encoding

This commit is contained in:
2026-07-11 22:50:45 -05:00
parent 20cd4ba6a0
commit 6ce1c39b8d
4 changed files with 177 additions and 17 deletions
+5 -3
View File
@@ -94,9 +94,11 @@ must never be reordered. Append new paths, replace removed paths with `null`, an
edit a path in place for a logical rename or move. Node IDs therefore cannot edit a path in place for a logical rename or move. Node IDs therefore cannot
contain `>`. contain `>`.
The fragment contains independently trimmed checked (`c`) and expanded (`x`) The fragment adaptively encodes checked (`c`) and expanded (`x`) state using the
bitsets. Bits are least-significant-bit first within each byte. Missing bits shortest of a dense bitset, sparse enabled slots, or sparse disabled slots.
default to unchecked and collapsed; malformed or unsupported state is ignored. Sparse slot numbers are delta-encoded variable-length integers. A coverage value
in the disabled-slot form ensures nodes appended later still default to unchecked
and collapsed. Malformed or unsupported state is ignored.
The helpers `encodeTreeState`, `decodeTreeState`, `applyTreeState`, and The helpers `encodeTreeState`, `decodeTreeState`, `applyTreeState`, and
`restoreStateFromLocation` are also exported for custom integrations. `restoreStateFromLocation` are also exported for custom integrations.
Use `validateManifestEvolution(previous, next)` in a build check to reject Use `validateManifestEvolution(previous, next)` in a build check to reject
+24
View File
@@ -1,5 +1,29 @@
# Tree UI State Serialization Design # Tree UI State Serialization Design
## Implementation amendment: adaptive fields
The implemented version 1 format retains the manifest, URL parameters, stable
slots, and default-state behavior described below, but adaptively encodes each
`c` and `x` field. This amendment supersedes the document's later recommendation
that fields always use dense bitsets.
Each decoded field still represents one Boolean per manifest slot. Before
Base64URL encoding, its first byte selects the shortest representation:
* `0`: dense, least-significant-bit-first bitset
* `1`: delta-encoded variable-length indexes of enabled slots
* `2`: manifest coverage followed by delta-encoded indexes of disabled slots
Mode 2's coverage value preserves forward compatibility: slots appended beyond
the encoded coverage remain false rather than inheriting the enabled state.
Empty all-false fields remain omitted. The encoder compares all three valid
representations independently for checked and expanded state and emits the
shortest. Sparse indexes are ascending, delta encoded, and stored as unsigned
base-128 variable-length integers.
This adaptive layout was adopted before deployment, so it uses serialization
version `1`; no dense-only version 1 URLs need backward compatibility.
## 1. Purpose ## 1. Purpose
Implement a compact, shareable, client-side serialization format for storing tree widget state in a URL. Implement a compact, shareable, client-side serialization format for storing tree widget state in a URL.
+122 -12
View File
@@ -75,6 +75,9 @@ export function base64UrlToBytes(value, maximumLength = Infinity) {
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]*$/.test(value) || value.length % 4 === 1) { if (typeof value !== 'string' || !/^[A-Za-z0-9_-]*$/.test(value) || value.length % 4 === 1) {
throw new Error('Invalid Base64URL value.'); throw new Error('Invalid Base64URL value.');
} }
if (Number.isFinite(maximumLength) && value.length > Math.ceil(maximumLength * 4 / 3) + 2) {
throw new Error('Encoded tree state exceeds the manifest size.');
}
const base64 = value.replace(/-/g, '+').replace(/_/g, '/'); const base64 = value.replace(/-/g, '+').replace(/_/g, '/');
const binary = atob(base64 + '='.repeat((4 - base64.length % 4) % 4)); const binary = atob(base64 + '='.repeat((4 - base64.length % 4) % 4));
if (binary.length > maximumLength) throw new Error('Encoded tree state exceeds the manifest size.'); if (binary.length > maximumLength) throw new Error('Encoded tree state exceeds the manifest size.');
@@ -87,6 +90,99 @@ function trimTrailingZeroBytes(bytes) {
return bytes.slice(0, end); return bytes.slice(0, end);
} }
function encodeUnsignedVarint(value) {
const bytes = [];
do {
let byte = value & 0x7f;
value = Math.floor(value / 128);
if (value) byte |= 0x80;
bytes.push(byte);
} while (value);
return bytes;
}
function decodeUnsignedVarint(bytes, offset) {
let value = 0;
let multiplier = 1;
for (let index = offset; index < bytes.length && index < offset + 8; index++) {
const byte = bytes[index];
value += (byte & 0x7f) * multiplier;
if ((byte & 0x80) === 0) return { value, offset: index + 1 };
multiplier *= 128;
if (!Number.isSafeInteger(value) || !Number.isSafeInteger(multiplier)) break;
}
throw new Error('Invalid adaptive tree-state integer.');
}
function encodeSparseIndexes(indexes) {
const bytes = [];
let previous = -1;
for (const index of indexes) {
bytes.push(...encodeUnsignedVarint(index - previous - 1));
previous = index;
}
return bytes;
}
function decodeSparseIndexes(bytes, offset, limit) {
const indexes = new Set();
let previous = -1;
while (offset < bytes.length) {
const decoded = decodeUnsignedVarint(bytes, offset);
const index = previous + decoded.value + 1;
if (index >= limit) throw new Error('Sparse tree state contains an out-of-range slot.');
indexes.add(index);
previous = index;
offset = decoded.offset;
}
return indexes;
}
export function encodeAdaptiveBitset(values, slotCount) {
const enabled = [];
const disabled = [];
const denseBytes = new Uint8Array(Math.ceil(slotCount / 8));
for (const [slot, value] of values) {
(value ? enabled : disabled).push(slot);
setBit(denseBytes, slot, value);
}
enabled.sort((left, right) => left - right);
disabled.sort((left, right) => left - right);
if (enabled.length === 0) return new Uint8Array();
const candidates = [
Uint8Array.from([0, ...trimTrailingZeroBytes(denseBytes)]),
Uint8Array.from([1, ...encodeSparseIndexes(enabled)]),
Uint8Array.from([2, ...encodeUnsignedVarint(slotCount), ...encodeSparseIndexes(disabled)])
];
return candidates.reduce((shortest, candidate) =>
candidate.length < shortest.length ? candidate : shortest
);
}
export function decodeAdaptiveBitset(bytes, slotCount) {
if (bytes.length === 0) return () => false;
const mode = bytes[0];
if (mode === 0) {
const dense = bytes.slice(1);
if (dense.length > Math.ceil(slotCount / 8)) {
throw new Error('Dense tree state exceeds the manifest size.');
}
return slot => getBit(dense, slot);
}
if (mode === 1) {
const enabled = decodeSparseIndexes(bytes, 1, slotCount);
return slot => enabled.has(slot);
}
if (mode === 2) {
const coverage = decodeUnsignedVarint(bytes, 1);
if (coverage.value > slotCount) throw new Error('Tree-state coverage exceeds the manifest size.');
const disabled = decodeSparseIndexes(bytes, coverage.offset, coverage.value);
return slot => slot < coverage.value && !disabled.has(slot);
}
throw new Error('Unknown adaptive tree-state mode.');
}
function asManifest(manifest) { function asManifest(manifest) {
return manifest?.pathToSlot instanceof Map ? manifest : loadManifest(manifest); return manifest?.pathToSlot instanceof Map ? manifest : loadManifest(manifest);
} }
@@ -127,9 +223,8 @@ export function validateManifestEvolution(previous, next) {
export function encodeTreeState(tree, manifest) { export function encodeTreeState(tree, manifest) {
const normalized = asManifest(manifest); const normalized = asManifest(manifest);
const byteLength = Math.ceil(normalized.slots.length / 8); const checkedValues = new Map();
const checked = new Uint8Array(byteLength); const expandedValues = new Map();
const expanded = new Uint8Array(byteLength);
for (const [path, node] of tree.nodesByPath) { for (const [path, node] of tree.nodesByPath) {
const slot = normalized.pathToSlot.get(path); const slot = normalized.pathToSlot.get(path);
@@ -137,12 +232,12 @@ export function encodeTreeState(tree, manifest) {
const item = tree.itemForNode(node.id); const item = tree.itemForNode(node.id);
const checkbox = item && tree.directCheckbox(item); const checkbox = item && tree.directCheckbox(item);
const toggle = item?.querySelector(':scope > .checkbox-tree__row > .checkbox-tree__toggle'); const toggle = item?.querySelector(':scope > .checkbox-tree__row > .checkbox-tree__toggle');
setBit(checked, slot, Boolean(checkbox?.checked)); checkedValues.set(slot, Boolean(checkbox?.checked));
setBit(expanded, slot, toggle?.getAttribute('aria-expanded') === 'true'); expandedValues.set(slot, toggle?.getAttribute('aria-expanded') === 'true');
} }
const checkedValue = bytesToBase64Url(trimTrailingZeroBytes(checked)); const checkedValue = bytesToBase64Url(encodeAdaptiveBitset(checkedValues, normalized.slots.length));
const expandedValue = bytesToBase64Url(trimTrailingZeroBytes(expanded)); const expandedValue = bytesToBase64Url(encodeAdaptiveBitset(expandedValues, normalized.slots.length));
return { version: SERIALIZATION_VERSION, checked: checkedValue || null, expanded: expandedValue || null }; return { version: SERIALIZATION_VERSION, checked: checkedValue || null, expanded: expandedValue || null };
} }
@@ -153,13 +248,20 @@ export function decodeTreeState(fragment, manifest) {
if (params.get('v') !== String(SERIALIZATION_VERSION)) { if (params.get('v') !== String(SERIALIZATION_VERSION)) {
return { applied: false, version: params.get('v'), warnings: ['Missing or unsupported serialization version.'] }; return { applied: false, version: params.get('v'), warnings: ['Missing or unsupported serialization version.'] };
} }
const maximumLength = Math.ceil(normalized.slots.length / 8); const maximumLength = Math.max(
Math.ceil(normalized.slots.length / 8) + 1,
normalized.slots.length * 2 + 10
);
try { try {
const checked = base64UrlToBytes(params.get('c') ?? '', maximumLength);
const expanded = base64UrlToBytes(params.get('x') ?? '', maximumLength);
decodeAdaptiveBitset(checked, normalized.slots.length);
decodeAdaptiveBitset(expanded, normalized.slots.length);
return { return {
applied: true, applied: true,
version: SERIALIZATION_VERSION, version: SERIALIZATION_VERSION,
checked: base64UrlToBytes(params.get('c') ?? '', maximumLength), checked,
expanded: base64UrlToBytes(params.get('x') ?? '', maximumLength), expanded,
warnings: [] warnings: []
}; };
} catch (error) { } catch (error) {
@@ -170,13 +272,21 @@ export function decodeTreeState(fragment, manifest) {
export function applyTreeState(tree, manifest, state) { export function applyTreeState(tree, manifest, state) {
if (!state?.applied) return false; if (!state?.applied) return false;
const normalized = asManifest(manifest); const normalized = asManifest(manifest);
let isChecked;
let isExpanded;
try {
isChecked = decodeAdaptiveBitset(state.checked, normalized.slots.length);
isExpanded = decodeAdaptiveBitset(state.expanded, normalized.slots.length);
} catch {
return false;
}
for (const [path, node] of tree.nodesByPath) { for (const [path, node] of tree.nodesByPath) {
const slot = normalized.pathToSlot.get(path); const slot = normalized.pathToSlot.get(path);
if (slot === undefined) continue; if (slot === undefined) continue;
const item = tree.itemForNode(node.id); const item = tree.itemForNode(node.id);
const checkbox = item && tree.directCheckbox(item); const checkbox = item && tree.directCheckbox(item);
if (checkbox && !checkbox.disabled) checkbox.checked = getBit(state.checked, slot); if (checkbox && !checkbox.disabled) checkbox.checked = isChecked(slot);
tree.setExpanded(item, getBit(state.expanded, slot)); tree.setExpanded(item, isExpanded(slot));
} }
tree.updateAllParentCheckboxes(); tree.updateAllParentCheckboxes();
return true; return true;
+26 -2
View File
@@ -13,6 +13,8 @@ const {
base64UrlToBytes, base64UrlToBytes,
bytesToBase64Url, bytesToBase64Url,
decodeTreeState, decodeTreeState,
decodeAdaptiveBitset,
encodeAdaptiveBitset,
getBit, getBit,
loadManifest, loadManifest,
setBit, setBit,
@@ -136,6 +138,28 @@ test('Base64URL encoding is unpadded, URL-safe, and round trips', () => {
assert.throws(() => base64UrlToBytes('not valid!'), /Invalid Base64URL/); assert.throws(() => base64UrlToBytes('not valid!'), /Invalid Base64URL/);
}); });
test('adaptive bitsets choose sparse enabled and disabled representations', () => {
const mostlyDisabled = new Map(Array.from(
{ length: 628 },
(_, slot) => [slot, slot === 2 || slot === 400]
));
const sparseEnabled = encodeAdaptiveBitset(mostlyDisabled, 628);
assert.equal(sparseEnabled[0], 1);
const enabled = decodeAdaptiveBitset(sparseEnabled, 628);
assert.equal(enabled(2), true);
assert.equal(enabled(3), false);
assert.equal(enabled(400), true);
const nearlyAllEnabled = new Map(Array.from({ length: 100 }, (_, slot) => [slot, slot !== 7]));
const sparseDisabled = encodeAdaptiveBitset(nearlyAllEnabled, 100);
assert.equal(sparseDisabled[0], 2);
const enabledExcept = decodeAdaptiveBitset(sparseDisabled, 105);
assert.equal(enabledExcept(6), true);
assert.equal(enabledExcept(7), false);
assert.equal(enabledExcept(99), true);
assert.equal(enabledExcept(100), false);
});
test('loads text and JSON manifests while retaining tombstone slots', () => { test('loads text and JSON manifests while retaining tombstone slots', () => {
const text = loadManifest('# tree-state-manifest\nroot\n!deleted\nroot>child\n'); const text = loadManifest('# tree-state-manifest\nroot\n!deleted\nroot>child\n');
assert.equal(text.slots.length, 3); assert.equal(text.slots.length, 3);
@@ -170,7 +194,7 @@ test('serializes and restores checked and expanded state through a fragment', ()
checkbox(first.container, 'first').click(); checkbox(first.container, 'first').click();
first.container.querySelector('.checkbox-tree__toggle').click(); first.container.querySelector('.checkbox-tree__toggle').click();
const fragment = first.tree.serializeStateToFragment(manifest); const fragment = first.tree.serializeStateToFragment(manifest);
assert.equal(fragment, '#v=1&c=Ag&x=AQ'); assert.equal(fragment, '#v=1&c=AAI&x=AAE');
const second = createTree({ manifest }); const second = createTree({ manifest });
const result = second.tree.restoreStateFromLocation(manifest, { hash: fragment }); const result = second.tree.restoreStateFromLocation(manifest, { hash: fragment });
@@ -201,7 +225,7 @@ test('malformed, oversized, and unsupported URL state fails safely', () => {
const manifest = loadManifest({ schema: 1, nodes: ['parent', 'parent>first', 'parent>second'] }); const manifest = loadManifest({ schema: 1, nodes: ['parent', 'parent>first', 'parent>second'] });
assert.equal(decodeTreeState('#v=2&c=AQ', manifest).applied, false); assert.equal(decodeTreeState('#v=2&c=AQ', manifest).applied, false);
assert.equal(decodeTreeState('#v=1&c=!', manifest).applied, false); assert.equal(decodeTreeState('#v=1&c=!', manifest).applied, false);
assert.equal(decodeTreeState('#v=1&c=AQA', manifest).applied, false); assert.equal(decodeTreeState('#v=1&c=Aw', manifest).applied, false);
const { tree } = createTree({ initiallySelected: true }); const { tree } = createTree({ initiallySelected: true });
const result = tree.restoreStateFromLocation(manifest, { hash: '#v=1&c=!' }); const result = tree.restoreStateFromLocation(manifest, { hash: '#v=1&c=!' });