Site builder: ship head code to published pages + fix 6 adversarial-review Minors #4
@@ -2,7 +2,7 @@ import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { createRoot, Root } from 'react-dom/client';
|
import { createRoot, Root } from 'react-dom/client';
|
||||||
import { act } from 'react-dom/test-utils';
|
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 /
|
/* PageContext only needs `useEditor` from @craftjs/core (for query.serialize /
|
||||||
actions.deserialize during page switches) — mock just that so PageProvider
|
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
|
/* addPage mints ids via nextPageId() (timestamp + monotonic counter, M-3),
|
||||||
in the same millisecond and collide on id, which is an existing, unrelated
|
so same-millisecond calls no longer collide on id. Date.now() is still
|
||||||
bug (id collision, not slug collision) — out of scope here but it makes
|
pinned/advanced here for determinism across the slug-dedupe assertions
|
||||||
these tests flaky since a colliding id defeats the "other pages" slug
|
below, independent of wall-clock timing. */
|
||||||
lookup. Force distinct ids so the slug-dedupe assertions below are stable. */
|
|
||||||
let dateNowSpy: ReturnType<typeof vi.spyOn>;
|
let dateNowSpy: ReturnType<typeof vi.spyOn>;
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
let counter = 1_700_000_000_000;
|
let counter = 1_700_000_000_000;
|
||||||
@@ -48,6 +47,23 @@ function unmount() {
|
|||||||
container.remove();
|
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', () => {
|
describe('uniqueSlug', () => {
|
||||||
test('returns base unchanged when no collision', () => {
|
test('returns base unchanged when no collision', () => {
|
||||||
expect(uniqueSlug('about', ['index', 'contact'])).toBe('about');
|
expect(uniqueSlug('about', ['index', 'contact'])).toBe('about');
|
||||||
|
|||||||
@@ -43,6 +43,20 @@ interface PageContextValue {
|
|||||||
const HEADER_ID = '__header__';
|
const HEADER_ID = '__header__';
|
||||||
const FOOTER_ID = '__footer__';
|
const FOOTER_ID = '__footer__';
|
||||||
|
|
||||||
|
// M-3: `page_${Date.now()}` alone collides when two pages are minted inside
|
||||||
|
// the same millisecond (addPage called twice in quick succession, or two AI
|
||||||
|
// replaceAllPages entries) -- then rename/delete/save operate on both pages
|
||||||
|
// at once since they share an id. A module-scoped monotonic counter,
|
||||||
|
// combined with the timestamp, guarantees uniqueness regardless of how many
|
||||||
|
// ids are minted within the same millisecond. This is state/id-minting code
|
||||||
|
// (not toHtml/export), so Date.now() here is fine -- see task-minors-brief.md.
|
||||||
|
let pageIdCounter = 0;
|
||||||
|
|
||||||
|
/** Mints a unique page id: timestamp (base36) + a monotonic per-process counter (base36). */
|
||||||
|
export function nextPageId(): string {
|
||||||
|
return 'page_' + Date.now().toString(36) + '_' + (++pageIdCounter).toString(36);
|
||||||
|
}
|
||||||
|
|
||||||
const EMPTY_CANVAS =
|
const EMPTY_CANVAS =
|
||||||
'{"ROOT":{"type":{"resolvedName":"Container"},"isCanvas":true,"props":{"style":{"minHeight":"100vh","backgroundColor":"#ffffff"},"tag":"div"},"displayName":"Container","custom":{},"hidden":false,"nodes":[],"linkedNodes":{}}}';
|
'{"ROOT":{"type":{"resolvedName":"Container"},"isCanvas":true,"props":{"style":{"minHeight":"100vh","backgroundColor":"#ffffff"},"tag":"div"},"displayName":"Container","custom":{},"hidden":false,"nodes":[],"linkedNodes":{}}}';
|
||||||
|
|
||||||
@@ -338,7 +352,7 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
const addPage = useCallback(
|
const addPage = useCallback(
|
||||||
(name: string, slug: string) => {
|
(name: string, slug: string) => {
|
||||||
const requestedSlug = slug || slugify(name);
|
const requestedSlug = slug || slugify(name);
|
||||||
const id = `page_${Date.now()}`;
|
const id = nextPageId();
|
||||||
|
|
||||||
// Save current page first
|
// Save current page first
|
||||||
saveCurrentState();
|
saveCurrentState();
|
||||||
@@ -452,7 +466,7 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
const slug = i === 0 ? 'index' : uniqueSlug(slugify(p.name), seenSlugs);
|
const slug = i === 0 ? 'index' : uniqueSlug(slugify(p.name), seenSlugs);
|
||||||
seenSlugs.push(slug);
|
seenSlugs.push(slug);
|
||||||
return {
|
return {
|
||||||
id: i === 0 ? 'home' : `page_${Date.now()}_${i}`,
|
id: i === 0 ? 'home' : nextPageId(),
|
||||||
name: p.name,
|
name: p.name,
|
||||||
slug,
|
slug,
|
||||||
craftState: treeToCraftState(p.tree),
|
craftState: treeToCraftState(p.tree),
|
||||||
|
|||||||
@@ -120,8 +120,24 @@ describe('scopeId', () => {
|
|||||||
expect(id1).not.toBe(id2);
|
expect(id1).not.toBe(id2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('node id is slugified (non-alphanumeric characters stripped, lowercased)', () => {
|
test('output is a valid CSS ident: prefix_hash', () => {
|
||||||
expect(scopeId('Node ID! 123', 'seed', 'sb')).toBe('sb_nodeid123');
|
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', () => {
|
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.
|
* available.
|
||||||
*/
|
*/
|
||||||
export function scopeId(nodeId: string | undefined, fallbackSeed: string, prefix: string): string {
|
export function scopeId(nodeId: string | undefined, fallbackSeed: string, prefix: string): string {
|
||||||
const slug = (nodeId || '').toString().toLowerCase().replace(/[^a-z0-9]+/g, '');
|
// M-4: hash the raw node id (via the same djb2 `stableHash` used for the
|
||||||
return `${prefix}_${slug || stableHash(fallbackSeed)}`;
|
// 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
|
// Shared enum allowlists for `toHtml` attribute sinks fed by props that are
|
||||||
|
|||||||
Reference in New Issue
Block a user