fix: unique addPage ids + collision-free scopeId hashing
M-3: PageContext.addPage minted ids from bare `page_${Date.now()}` --
two adds inside the same millisecond collided on id, so a subsequent
rename/delete/save silently acted on both pages at once. Added a
module-scoped monotonic counter combined with the timestamp
(nextPageId(), exported for direct unit testing) and used it
everywhere an addPage-style id is minted (addPage, replaceAllPages).
M-4: scopeId() lowercased + stripped non-alphanumeric characters from
the node id into a slug, so two node ids differing only by
case/punctuation (e.g. "AbC" vs "abc", or "a-b" vs "ab") collapsed
onto the same scope -- defeating the whole point of scoping ids per
node (M-1/Menu/Tabs/ColumnLayout/Gallery/etc. all rely on it). Now
hashes the raw node id via the existing djb2 stableHash() instead of
slugifying it: still deterministic (same id -> same scope) and a valid
CSS ident, but collision-resistant across case/punctuation. This
changes the exact scope strings Menu/Tabs/ColumnLayout/Gallery/etc.
emit -- expected and fine, since none of their tests pinned an exact
scope value (all already asserted structure/uniqueness).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -120,8 +120,24 @@ describe('scopeId', () => {
|
||||
expect(id1).not.toBe(id2);
|
||||
});
|
||||
|
||||
test('node id is slugified (non-alphanumeric characters stripped, lowercased)', () => {
|
||||
expect(scopeId('Node ID! 123', 'seed', 'sb')).toBe('sb_nodeid123');
|
||||
test('output is a valid CSS ident: prefix_hash', () => {
|
||||
expect(scopeId('Node ID! 123', 'seed', 'sb')).toMatch(/^sb_[a-z0-9]+$/);
|
||||
});
|
||||
|
||||
test('M-4: case-differing node ids do not collapse to the same scope (no case-fold collision)', () => {
|
||||
expect(scopeId('AbC', 'seed', 'sb')).not.toBe(scopeId('abc', 'seed', 'sb'));
|
||||
});
|
||||
|
||||
test('M-4: punctuation-differing node ids do not collapse to the same scope', () => {
|
||||
expect(scopeId('a-b', 'seed', 'sb')).not.toBe(scopeId('ab', 'seed', 'sb'));
|
||||
});
|
||||
|
||||
test('M-4: same input always yields the identical scope id', () => {
|
||||
expect(scopeId('some-node-id', 'seed', 'sb')).toBe(scopeId('some-node-id', 'seed', 'sb'));
|
||||
});
|
||||
|
||||
test('M-4: output always matches a valid CSS ident pattern', () => {
|
||||
expect(scopeId('Weird!! Node--ID__123', 'seed', 'sb')).toMatch(/^[a-z]+_[a-z0-9]+$/i);
|
||||
});
|
||||
|
||||
test('no nodeId (legacy 2-arg call sites) falls back to a deterministic hash of the seed, not Math.random', () => {
|
||||
|
||||
@@ -151,8 +151,14 @@ export function stableHash(seed: string): string {
|
||||
* available.
|
||||
*/
|
||||
export function scopeId(nodeId: string | undefined, fallbackSeed: string, prefix: string): string {
|
||||
const slug = (nodeId || '').toString().toLowerCase().replace(/[^a-z0-9]+/g, '');
|
||||
return `${prefix}_${slug || stableHash(fallbackSeed)}`;
|
||||
// M-4: hash the raw node id (via the same djb2 `stableHash` used for the
|
||||
// fallback path below) rather than lowercasing + stripping punctuation
|
||||
// into a slug. A slug collapses distinct ids that differ only by
|
||||
// case/punctuation (e.g. "AbC" and "abc", or "a-b" and "ab") onto the same
|
||||
// scope; hashing the untouched string keeps them distinct while staying
|
||||
// deterministic and producing a valid CSS ident (prefix + '_' + [a-z0-9]+).
|
||||
const seed = (nodeId || '').toString();
|
||||
return `${prefix}_${stableHash(seed || fallbackSeed)}`;
|
||||
}
|
||||
|
||||
// Shared enum allowlists for `toHtml` attribute sinks fed by props that are
|
||||
|
||||
Reference in New Issue
Block a user