Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Coarse error trapping #1766

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions packages/host/app/components/error-trap.gts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { getComponentTemplate } from '@ember/component';

import type Owner from '@ember/owner';
import { getOwner } from '@ember/owner';
import Component from '@glimmer/component';
// @ts-expect-error
import { createConstRef } from '@glimmer/reference';
// @ts-expect-error
import { renderMain, inTransaction } from '@glimmer/runtime';

import { type ComponentLike } from '@glint/template';

import { modifier } from 'ember-modifier';

import { tracked } from 'tracked-built-ins';

import { CardError } from '@cardstack/runtime-common/error';

function render<Params>(
element: Element,
owner: Owner,
Content: ComponentLike<{ Args: { params: Params } }>,
params: Params,
): { rerender: () => void } {
// this needs to be a template-only component because the way we're invoking it
// just grabs the template and would drop any associated class.
const root = <template><Content @params={{params}} /></template>;

// clear any previous render work
removeChildren(element);

let { _runtime, _context, _owner, _builder } = owner.lookup(
'renderer:-dom',
) as any;
let self = createConstRef({}, 'this');
let layout = (getComponentTemplate as any)(root)(_owner).asLayout();
let iterator = renderMain(
_runtime,
_context,
_owner,
self,
_builder(_runtime.env, { element }),
layout,
);
let vm = (iterator as any).vm;

try {
let result: any;
inTransaction(_runtime.env, () => {
result = vm._execute();
});
return {
rerender() {
// NEXT: this needs to get wrapped with our own inTransaction just like the initial render so it doesn't interact with the default tracking frames.
result.rerender({ alwaysRevalidate: false });
},
};
} catch (err: any) {
// This is to compensate for the commitCacheGroup op code that is not called because
// of the error being thrown here. we do this so we can keep the TRANSACTION_STACK
// balanced (which would otherwise cause consumed tags to leak into subsequent frames).
// I'm not adding this to a "finally" because when there is no error, the VM will
// process an op code that will do this organically. It's only when there is an error
// that we need to step in and do this by hand. Within the vm[STACKS] is a the stack
// for the cache group. We need to call a commit for each item in this stack.
let vmSymbols = Object.fromEntries(
Object.getOwnPropertySymbols(vm).map((s) => [s.toString(), s]),
);
let stacks = vm[vmSymbols['Symbol(STACKS)']];
let stackSize = stacks.cache.stack.length;
for (let i = 0; i < stackSize; i++) {
vm.commitCacheGroup();
}

let error = new CardError(
`Encountered error rendering HTML for card: ${err.message}`,
);
error.additionalErrors = [err];
throw error;
}
}

function removeChildren(element: Element) {
let child = element.firstChild;
while (child) {
element.removeChild(child);
child = element.firstChild;
}
}

export default class ErrorTrap<T> extends Component<{
Args: {
content: ComponentLike<{ Args: { params: T } }>;
params: T;
};
}> {
@tracked failed = false;

renderer: { rerender(): void } | undefined;

attach = modifier((element) => {
try {
if (this.renderer) {
this.renderer.rerender();
} else {
this.renderer = render(
element,
getOwner(this)!,
this.args.content,
this.args.params,
);
}
this.failed = false;
} catch (err) {
debugger;
removeChildren(element);
this.failed = true;
this.renderer = undefined;
}
});

<template>
<div {{this.attach}} />
{{#if this.failed}}
<div data-test-error-trap>Something went wrong</div>
{{/if}}
</template>
}
143 changes: 143 additions & 0 deletions packages/host/tests/integration/components/error-trap-test.gts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { TemplateOnlyComponent } from '@ember/component/template-only';

import { settled } from '@ember/test-helpers';
import Component from '@glimmer/component';

import { module, test } from 'qunit';

import { TrackedObject } from 'tracked-built-ins';

import ErrorTrap from '@cardstack/host/components/error-trap';

import { renderComponent } from '../../helpers/render-component';
import { setupRenderingTest } from '../../helpers/setup';

module('Integration | error-trap', function (hooks) {
setupRenderingTest(hooks);

test('passes through when there is no error', async function (assert) {
const content = <template>
<div data-test='message'>{{@params.message}}</div>
</template> satisfies TemplateOnlyComponent<{
Args: { params: { message: string } };
}>;

const params = new TrackedObject({
message: 'hello',
});

await renderComponent(<template>
<ErrorTrap @content={{content}} @params={{params}} />
</template>);
assert.dom('[data-test="message"]').containsText('hello');
});

test('re-renders normally', async function (assert) {
const content = <template>
<div data-test='message'>{{@params.message}}</div>
</template> satisfies TemplateOnlyComponent<{
Args: { params: { message: string } };
}>;

const params = new TrackedObject({
message: 'hello',
});

await renderComponent(<template>
<ErrorTrap @content={{content}} @params={{params}} />
</template>);
params.message = 'goodbye';
await settled();
assert.dom('[data-test="message"]').containsText('goodbye');
});

test('traps error on initial render', async function (assert) {
class Content extends Component<{
Args: { params: { mode: boolean } };
}> {
get message() {
if (this.args.params.mode) {
return 'Everything OK';
} else {
throw new Error('intentional exception');
}
}

<template>
<div data-test='message'>{{this.message}}</div>
</template>
}

const params = new TrackedObject({
mode: false,
});

await renderComponent(<template>
<ErrorTrap @content={{Content}} @params={{params}} />
</template>);
assert.dom('[data-test-error-trap]').exists();
});

test('traps error on re-render', async function (assert) {
class Content extends Component<{
Args: { params: { mode: boolean } };
}> {
get message() {
if (this.args.params.mode) {
return 'Everything OK';
} else {
throw new Error('intentional exception');
}
}

<template>
<div data-test='message'>{{this.message}}</div>
</template>
}

const params = new TrackedObject({
mode: true,
});

await renderComponent(<template>
<ErrorTrap @content={{Content}} @params={{params}} />
</template>);
assert.dom('[data-test="message"]').containsText('Everything OK');

params.mode = false;
await settled();
assert.dom('[data-test-error-trap]').exists();
});

test('can recover', async function (assert) {
class Content extends Component<{
Args: { params: { mode: boolean } };
}> {
get message() {
if (this.args.params.mode) {
return 'Everything OK';
} else {
throw new Error('intentional exception');
}
}

<template>
<div data-test='message'>{{this.message}}</div>
</template>
}

const params = new TrackedObject({
mode: false,
});

await renderComponent(<template>
<ErrorTrap @content={{Content}} @params={{params}} />
</template>);

assert.dom('[data-test-error-trap]').exists();

params.mode = true;
await settled();
assert.dom('[data-test="message"]').containsText('Everything OK');
});
});
Loading