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:
@@ -2,7 +2,7 @@ import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { createRoot, Root } from 'react-dom/client';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { PageProvider, usePages, uniqueSlug } from './PageContext';
|
||||
import { PageProvider, usePages, uniqueSlug, nextPageId } from './PageContext';
|
||||
|
||||
/* PageContext only needs `useEditor` from @craftjs/core (for query.serialize /
|
||||
actions.deserialize during page switches) — mock just that so PageProvider
|
||||
@@ -15,11 +15,10 @@ vi.mock('@craftjs/core', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
/* addPage mints ids from `Date.now()`. Two adds inside the same test can land
|
||||
in the same millisecond and collide on id, which is an existing, unrelated
|
||||
bug (id collision, not slug collision) — out of scope here but it makes
|
||||
these tests flaky since a colliding id defeats the "other pages" slug
|
||||
lookup. Force distinct ids so the slug-dedupe assertions below are stable. */
|
||||
/* addPage mints ids via nextPageId() (timestamp + monotonic counter, M-3),
|
||||
so same-millisecond calls no longer collide on id. Date.now() is still
|
||||
pinned/advanced here for determinism across the slug-dedupe assertions
|
||||
below, independent of wall-clock timing. */
|
||||
let dateNowSpy: ReturnType<typeof vi.spyOn>;
|
||||
beforeEach(() => {
|
||||
let counter = 1_700_000_000_000;
|
||||
@@ -48,6 +47,23 @@ function unmount() {
|
||||
container.remove();
|
||||
}
|
||||
|
||||
describe('nextPageId (M-3: no same-millisecond id collision)', () => {
|
||||
test('two calls yield distinct ids even when Date.now() is pinned to a constant', () => {
|
||||
const spy = vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000);
|
||||
try {
|
||||
const id1 = nextPageId();
|
||||
const id2 = nextPageId();
|
||||
expect(id1).not.toBe(id2);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('ids are prefixed with "page_"', () => {
|
||||
expect(nextPageId()).toMatch(/^page_/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uniqueSlug', () => {
|
||||
test('returns base unchanged when no collision', () => {
|
||||
expect(uniqueSlug('about', ['index', 'contact'])).toBe('about');
|
||||
|
||||
Reference in New Issue
Block a user