Skip to content

Commit 84e03d8

Browse files
fix: keep server stores until destroy() and compare object query hashes by content (#48)
On the server every store armed a 500 ms init timer and a 10 s touched timer although a request renders once and calls destroy(); the timers only kept ~1.7 MB of state and 20 handles alive per request. The manager now detects the server and arms no destroy timers there. SuspenseQuery compared hashes with strict equality, so an object or array hash created on every render never matched the stored one and the query was re-run and re-thrown forever. Object hashes are now serialised before they are stored and compared.
1 parent 4f195b7 commit 84e03d8

5 files changed

Lines changed: 85 additions & 2 deletions

File tree

‎__tests__/manager.ts‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,52 @@ describe('Manager', () => {
506506
clock.restore();
507507
});
508508

509+
it('should not arm destroy timers on the server and remove stores on destroy', async () => {
510+
const clock = sandbox.useFakeTimers();
511+
const onDestroy = sandbox.stub();
512+
513+
class RelativeStore {
514+
public libStoreId?: string;
515+
public libStoreStatus?: StoreStatus;
516+
public libDestroyTimer?: ReturnType<typeof setTimeout>;
517+
public isGlobal?: boolean;
518+
public onDestroy = onDestroy;
519+
520+
constructor() {}
521+
}
522+
523+
const manager = new Manager({ options: { destroyTimers: { init: 1, touched: 1, unused: 1 } } });
524+
525+
manager.isServer = true;
526+
527+
const result = manager.createStores(
528+
[['relativeStore', RelativeStore]],
529+
parentContextId,
530+
managedContextId,
531+
suspenseId,
532+
managedComponentName,
533+
);
534+
const store = result.relativeStores.relativeStore as RelativeStore;
535+
536+
manager.touchedStores({ relativeStore: store });
537+
manager.mountStores(managedContextId, result)();
538+
539+
expect(store.libStoreStatus).to.equal(StoreStatus.unused);
540+
expect(store.libDestroyTimer).to.equal(undefined);
541+
542+
await clock.tickAsync(100);
543+
544+
expect(manager.getStores().has(store.libStoreId!)).to.equal(true);
545+
sinon.assert.notCalled(onDestroy);
546+
547+
manager.destroy();
548+
549+
expect(manager.getStores().size).to.equal(0);
550+
sinon.assert.calledOnce(onDestroy);
551+
552+
clock.restore();
553+
});
554+
509555
it('should run cleanup returned from init when store is destroyed', async () => {
510556
const clock = sandbox.useFakeTimers();
511557
const onDestroy = sandbox.stub();

‎__tests__/suspense-query.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,24 @@ describe('SuspenseQuery', () => {
4646
expect(target.query(() => Promise.resolve('unused'), { hash: 'hash-1' })).to.be.undefined;
4747
});
4848

49+
it('should treat object hashes by content for queries and subqueries', async () => {
50+
const store: Record<string, unknown> = {};
51+
const target = new SuspenseQuery(store);
52+
const request = () => Promise.resolve('done');
53+
54+
expect(() => target.query(request, { hash: { page: 1, tags: ['a'] } })).to.throw();
55+
await Promise.resolve();
56+
await Promise.resolve();
57+
58+
expect(store.sR).to.deep.equal({ hash: '{"page":1,"tags":["a"]}', done: true });
59+
expect(target.query(request, { hash: { page: 1, tags: ['a'] } })).to.be.undefined;
60+
expect(() => target.query(request, { hash: { page: 2, tags: ['a'] } })).to.throw();
61+
62+
expect(target.subquery(request, { id: subqueryId, hash: { page: 1 } })).to.be.undefined;
63+
expect(target.subquery(request, { id: subqueryId, hash: { page: 1 } })).to.be.undefined;
64+
expect(() => target.subquery(request, { id: subqueryId, hash: { page: 2 } })).to.throw();
65+
});
66+
4967
it.each(['resolve', 'reject'] as const)(
5068
'should ignore an obsolete query that settles with %s',
5169
async (settlement) => {

‎docs/guide/ssr.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ Examples that need cleanup:
4343
- cleanup returned from `init`
4444
- internal store cleanup
4545

46+
On the server the manager arms no destroy timers (`destroyTimers` only apply in the browser): stores live exactly as long as the request, and `destroy()` is the only teardown.
47+
4648
## Practical guidance
4749

4850
Do:

‎src/manager.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,11 @@ class Manager {
9292
/**
9393
* @constructor
9494
*/
95+
/**
96+
* Detect server side: stores live until the manager is destroyed, no timers are armed
97+
*/
98+
public isServer = typeof window === 'undefined';
99+
95100
public constructor({ initState, storesParams, storage, options, logger }: IManagerParams = {}) {
96101
this.initState = initState || {};
97102
this.storesParams = storesParams || {};
@@ -613,6 +618,11 @@ class Manager {
613618

614619
clearTimeout(store.libDestroyTimer);
615620

621+
// A request renders once and calls destroy(): timers would only keep the state alive longer.
622+
if (this.isServer) {
623+
return;
624+
}
625+
616626
let destroyTime = 0;
617627

618628
switch (status) {

‎src/suspense-query.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ interface ISuspenseQueryOptions {
1616
hash?: unknown;
1717
}
1818

19+
/**
20+
* Objects and arrays are compared by content: a fresh hash object on every render must still match.
21+
*/
22+
const normalizeHash = (hash: unknown): unknown =>
23+
typeof hash === 'object' && hash !== null ? JSON.stringify(hash) : hash;
24+
1925
interface ISuspenseSubqueryOptions {
2026
id: string;
2127
hash: unknown;
@@ -142,7 +148,7 @@ class SuspenseQuery {
142148
promise: () => Promise<TReturn>,
143149
options: ISuspenseQueryOptions = {},
144150
): TReturn | undefined => {
145-
const { hash = '' } = options;
151+
const hash = normalizeHash(options.hash ?? '');
146152
const { fieldName } = this.params;
147153

148154
if (this.isComplete(hash)) {
@@ -190,7 +196,8 @@ class SuspenseQuery {
190196
promise: () => Promise<TReturn>,
191197
options: ISuspenseSubqueryOptions,
192198
): TReturn | undefined => {
193-
const { id, hash } = options;
199+
const { id } = options;
200+
const hash = normalizeHash(options.hash);
194201
const subquery = this.subqueries.get(id);
195202

196203
// skip first run

0 commit comments

Comments
 (0)