fix(build): rename threadSummary.ts — case-collision broke the Windows release
threadSummary.ts (pure helpers) and ThreadSummary.tsx (chip component) lived in the same directory differing only by case. On the case-insensitive Windows release runner, RoomTimeline's extensionless import of ./thread/ThreadSummary resolved .ts BEFORE .tsx and matched the helper module → rolldown MISSING_EXPORT "ThreadSummary" — invisible on every Linux/macOS build (and the cause of the earlier masked pdf.worker failure). Helper module renamed to threadSummaryData.ts (+ test), 3 importers updated. Prevention: new caseCollision.test.ts walks src/ and fails on any same-directory names differing only by case (extensionless compare, so Foo.tsx vs foo.ts is caught) — verified it fails on the pre-rename tree. Runs in the hard CI gate. Gates: tsc clean, eslint/prettier clean, build OK, 658/659 tests (1 IDB skip). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,133 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { EventStatus, MatrixEvent, RelationType } from 'matrix-js-sdk';
|
||||||
|
import { getThreadSummary, isPendingThreadReply } from './threadSummaryData';
|
||||||
|
|
||||||
|
// getThreadSummary reads either the live Thread (preferred) or the
|
||||||
|
// server-aggregated `m.thread` bundle. We stub only the members it touches and
|
||||||
|
// cast through `unknown` to MatrixEvent, mirroring the light mocking used in
|
||||||
|
// the state tests.
|
||||||
|
|
||||||
|
type ThreadStub = { length: number; lastReplyTs?: number };
|
||||||
|
type BundleStub = { count: number; latestTs?: number };
|
||||||
|
|
||||||
|
const makeRootEvent = (opts: { thread?: ThreadStub; bundle?: BundleStub }): MatrixEvent => {
|
||||||
|
const thread = opts.thread
|
||||||
|
? {
|
||||||
|
length: opts.thread.length,
|
||||||
|
lastReply: () =>
|
||||||
|
opts.thread?.lastReplyTs === undefined
|
||||||
|
? null
|
||||||
|
: ({ getTs: () => opts.thread?.lastReplyTs } as unknown as MatrixEvent),
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
getThread: () => thread,
|
||||||
|
getServerAggregatedRelation: (relType: string) => {
|
||||||
|
if (relType !== RelationType.Thread || !opts.bundle) return undefined;
|
||||||
|
return {
|
||||||
|
count: opts.bundle.count,
|
||||||
|
latest_event:
|
||||||
|
opts.bundle.latestTs === undefined
|
||||||
|
? undefined
|
||||||
|
: { origin_server_ts: opts.bundle.latestTs },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
} as unknown as MatrixEvent;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// getThreadSummary
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('prefers the live thread: count from length, latestTs from lastReply', () => {
|
||||||
|
const rootEvent = makeRootEvent({
|
||||||
|
thread: { length: 3, lastReplyTs: 1700 },
|
||||||
|
bundle: { count: 99, latestTs: 1 },
|
||||||
|
});
|
||||||
|
assert.deepEqual(getThreadSummary(rootEvent), { count: 3, latestTs: 1700 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('live thread with no replies yields undefined latestTs', () => {
|
||||||
|
const rootEvent = makeRootEvent({ thread: { length: 0 } });
|
||||||
|
assert.deepEqual(getThreadSummary(rootEvent), { count: 0, latestTs: undefined });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to the server bundle when no live thread', () => {
|
||||||
|
const rootEvent = makeRootEvent({ bundle: { count: 5, latestTs: 1234 } });
|
||||||
|
assert.deepEqual(getThreadSummary(rootEvent), { count: 5, latestTs: 1234 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bundle without latest_event yields undefined latestTs', () => {
|
||||||
|
const rootEvent = makeRootEvent({ bundle: { count: 2 } });
|
||||||
|
assert.deepEqual(getThreadSummary(rootEvent), { count: 2, latestTs: undefined });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns undefined when there is neither a thread nor a bundle', () => {
|
||||||
|
const rootEvent = makeRootEvent({});
|
||||||
|
assert.equal(getThreadSummary(rootEvent), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// isPendingThreadReply
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const ROOT = '$root:server';
|
||||||
|
|
||||||
|
const makeReply = (opts: {
|
||||||
|
status: EventStatus | null;
|
||||||
|
threadRootId?: string;
|
||||||
|
relation?: { rel_type?: string; event_id?: string } | null;
|
||||||
|
}): MatrixEvent =>
|
||||||
|
({
|
||||||
|
status: opts.status,
|
||||||
|
threadRootId: opts.threadRootId,
|
||||||
|
getRelation: () => opts.relation ?? null,
|
||||||
|
}) as unknown as MatrixEvent;
|
||||||
|
|
||||||
|
test('SENDING with matching threadRootId is pending', () => {
|
||||||
|
const event = makeReply({ status: EventStatus.SENDING, threadRootId: ROOT });
|
||||||
|
assert.equal(isPendingThreadReply(event, ROOT), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('NOT_SENT with matching threadRootId is pending', () => {
|
||||||
|
const event = makeReply({ status: EventStatus.NOT_SENT, threadRootId: ROOT });
|
||||||
|
assert.equal(isPendingThreadReply(event, ROOT), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SENDING resolved via the m.thread relation content is pending', () => {
|
||||||
|
const event = makeReply({
|
||||||
|
status: EventStatus.SENDING,
|
||||||
|
relation: { rel_type: RelationType.Thread, event_id: ROOT },
|
||||||
|
});
|
||||||
|
assert.equal(isPendingThreadReply(event, ROOT), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SENT (confirmed) event is not pending', () => {
|
||||||
|
const event = makeReply({ status: EventStatus.SENT, threadRootId: ROOT });
|
||||||
|
assert.equal(isPendingThreadReply(event, ROOT), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('null status is not pending', () => {
|
||||||
|
const event = makeReply({ status: null, threadRootId: ROOT });
|
||||||
|
assert.equal(isPendingThreadReply(event, ROOT), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SENDING but for a different thread is not pending', () => {
|
||||||
|
const event = makeReply({ status: EventStatus.SENDING, threadRootId: '$other:server' });
|
||||||
|
assert.equal(isPendingThreadReply(event, ROOT), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SENDING with a non-thread relation is not pending', () => {
|
||||||
|
const event = makeReply({
|
||||||
|
status: EventStatus.SENDING,
|
||||||
|
relation: { rel_type: RelationType.Reference, event_id: ROOT },
|
||||||
|
});
|
||||||
|
assert.equal(isPendingThreadReply(event, ROOT), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SENDING with no relation and no threadRootId is not pending', () => {
|
||||||
|
const event = makeReply({ status: EventStatus.SENDING });
|
||||||
|
assert.equal(isPendingThreadReply(event, ROOT), false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { EventStatus, IThreadBundledRelationship, MatrixEvent, RelationType } from 'matrix-js-sdk';
|
||||||
|
|
||||||
|
export type ThreadSummaryData = {
|
||||||
|
count: number;
|
||||||
|
latestTs: number | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summary data for a thread root's "N replies" chip.
|
||||||
|
*
|
||||||
|
* Prefers the live {@link Thread} object when it exists (it reflects local
|
||||||
|
* echo + pagination), otherwise falls back to the server-aggregated bundle
|
||||||
|
* (`unsigned['m.relations']['m.thread']`) so the chip renders before any
|
||||||
|
* Thread object has been created. Returns `undefined` when the root has no
|
||||||
|
* thread at all.
|
||||||
|
*/
|
||||||
|
export const getThreadSummary = (rootEvent: MatrixEvent): ThreadSummaryData | undefined => {
|
||||||
|
const thread = rootEvent.getThread();
|
||||||
|
if (thread) {
|
||||||
|
const lastReply = thread.lastReply();
|
||||||
|
return {
|
||||||
|
count: thread.length,
|
||||||
|
latestTs: lastReply?.getTs(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const bundle = rootEvent.getServerAggregatedRelation<IThreadBundledRelationship>(
|
||||||
|
RelationType.Thread,
|
||||||
|
);
|
||||||
|
if (bundle) {
|
||||||
|
return {
|
||||||
|
count: bundle.count,
|
||||||
|
latestTs: bundle.latest_event?.origin_server_ts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when `event` is a still-in-flight (local echo) reply belonging to the
|
||||||
|
* given thread root. Used to render the pending strip, since pending thread
|
||||||
|
* sends never enter the thread's timelineSet.
|
||||||
|
*/
|
||||||
|
export const isPendingThreadReply = (event: MatrixEvent, threadRootId: string): boolean => {
|
||||||
|
const { status } = event;
|
||||||
|
if (status !== EventStatus.SENDING && status !== EventStatus.NOT_SENT) return false;
|
||||||
|
|
||||||
|
// Prefer the SDK's resolved thread root id; fall back to the raw relation
|
||||||
|
// content for events the SDK hasn't associated with a thread yet.
|
||||||
|
if (event.threadRootId === threadRootId) return true;
|
||||||
|
|
||||||
|
const relation = event.getRelation();
|
||||||
|
return relation?.rel_type === RelationType.Thread && relation.event_id === threadRootId;
|
||||||
|
};
|
||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
ThreadEvent,
|
ThreadEvent,
|
||||||
} from 'matrix-js-sdk';
|
} from 'matrix-js-sdk';
|
||||||
import { getLinkedTimelines } from '../RoomTimeline';
|
import { getLinkedTimelines } from '../RoomTimeline';
|
||||||
import { isPendingThreadReply } from './threadSummary';
|
import { isPendingThreadReply } from './threadSummaryData';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve (or bootstrap) the live {@link Thread} for a root event.
|
* Resolve (or bootstrap) the live {@link Thread} for a root event.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
RoomEventHandlerMap,
|
RoomEventHandlerMap,
|
||||||
ThreadEvent,
|
ThreadEvent,
|
||||||
} from 'matrix-js-sdk';
|
} from 'matrix-js-sdk';
|
||||||
import { getThreadSummary, ThreadSummaryData } from '../features/room/thread/threadSummary';
|
import { getThreadSummary, ThreadSummaryData } from '../features/room/thread/threadSummaryData';
|
||||||
import { threadNotificationsAtom } from '../state/threadNotifications';
|
import { threadNotificationsAtom } from '../state/threadNotifications';
|
||||||
import { getThreadNotificationMode, ThreadNotificationMode } from '../utils/threadNotifications';
|
import { getThreadNotificationMode, ThreadNotificationMode } from '../utils/threadNotifications';
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { strict as assert } from 'node:assert';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import { readdirSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guard against same-directory filenames that differ only by case (e.g.
|
||||||
|
* `threadSummary.ts` vs `ThreadSummary.tsx`). On case-insensitive filesystems
|
||||||
|
* (the Windows release runner) an extensionless import of one can resolve to
|
||||||
|
* the OTHER file — rolldown tries `.ts` before `.tsx` — producing
|
||||||
|
* MISSING_EXPORT failures that never reproduce on the Linux/macOS machines the
|
||||||
|
* project is developed and web-deployed on. This broke the desktop release
|
||||||
|
* build twice before being diagnosed; this test makes the collision a local,
|
||||||
|
* immediate failure instead.
|
||||||
|
*/
|
||||||
|
const findCaseCollisions = (dir: string, collisions: string[]): void => {
|
||||||
|
const entries = readdirSync(dir, { withFileTypes: true });
|
||||||
|
const seen = new Map<string, string>();
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
// Compare basenames without extension: `Foo.tsx` collides with `foo.ts`
|
||||||
|
// because module resolution is extensionless.
|
||||||
|
const stem = entry.isDirectory() ? entry.name : entry.name.replace(/\.[^.]+$/, '');
|
||||||
|
const key = stem.toLowerCase();
|
||||||
|
const existing = seen.get(key);
|
||||||
|
if (existing !== undefined && existing !== stem) {
|
||||||
|
collisions.push(`${dir}: "${existing}" vs "${stem}"`);
|
||||||
|
}
|
||||||
|
if (existing === undefined) seen.set(key, stem);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
findCaseCollisions(join(dir, entry.name), collisions);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
test('no same-directory filenames differing only by case under src/', () => {
|
||||||
|
const collisions: string[] = [];
|
||||||
|
findCaseCollisions('src', collisions);
|
||||||
|
assert.deepEqual(
|
||||||
|
collisions,
|
||||||
|
[],
|
||||||
|
`Case-colliding names break Windows builds:\n${collisions.join('\n')}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user