Skip to content
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "prerelease",
"comment": "fix: add update scheduler fallback",
"packageName": "@microsoft/fast-element",
"email": "7559015+janechu@users.noreply.github.com",
"dependentChangeType": "none"
}
6 changes: 3 additions & 3 deletions packages/fast-element/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ For deep dives into specific areas, see the linked detailed documents.
| Declarative templating | `html` tagged template literal β†’ `ViewTemplate` β†’ compiled `HTMLView` |
| Declarative HTML runtime | `@microsoft/fast-element/declarative.js` β†’ `declarativeTemplate()`, `TemplateParser`; `@microsoft/fast-element/schema.js` β†’ `Schema` |
| Schema-driven extensions | `@microsoft/fast-element/attribute-map.js` and `@microsoft/fast-element/observer-map.js` β†’ map helpers usable with declarative or manually supplied schemas |
| Async DOM updates | `Updates` queue (batched, `requestAnimationFrame`-aligned) |
| Async DOM updates | `Updates` queue (batched, `requestAnimationFrame`-aligned with a `setTimeout` fallback) |
| Scoped styles | `css` tagged template literal β†’ `ElementStyles` β†’ `adoptedStylesheets` / `<style>` |
| Dependency injection | `DI` container, `@inject`, `@singleton`, `@transient`, resolvers |
| Context protocol | W3C community Context protocol (`Context.create`, `Context.for`) |
Expand Down Expand Up @@ -258,7 +258,7 @@ See [docs/template-bindings.md](./docs/template-bindings.md) for the full bindin
- `Updates.next()` – returns a `Promise` that resolves after the next flush.
- `Updates.setMode(isAsync)` – toggle async (default) vs. synchronous mode.

In async mode, the first `enqueue` call schedules a `requestAnimationFrame` callback that drains up to 1024 tasks per frame. Errors in tasks are deferred via `setTimeout` so they don't abort the remaining tasks.
In async mode, the first `enqueue` call schedules a `requestAnimationFrame` callback that drains up to 1024 tasks per frame. If `requestAnimationFrame` is unavailable, the queue falls back to `setTimeout` so non-browser runtimes still process async updates. Errors in tasks are deferred via `setTimeout` so they don't abort the remaining tasks.

Observable setters and `attributeChangedCallback` enqueue their DOM mutations through `Updates`, ensuring that multiple synchronous property changes result in only one DOM update per frame.

Expand Down Expand Up @@ -446,7 +446,7 @@ flowchart TD
NOTIFY["PropertyChangeNotifier.notify(propertyName)"]
SUBS["For each Subscriber in SubscriberSet\nsubscriber.handleChange(subject, propertyName)"]
ENQ["Updates.enqueue(binding task)"]
RAF["requestAnimationFrame fires\nUpdates.process()"]
RAF["requestAnimationFrame/setTimeout fires\nUpdates.process()"]
EVAL["ExpressionNotifier re-evaluates expression"]
DOM["DOM aspect updated\n(attribute / property / content / event)"]

Expand Down
5 changes: 3 additions & 2 deletions packages/fast-element/MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@

- **Native `globalThis` required**: `@microsoft/fast-element` no longer installs
a `globalThis` polyfill as a side effect. The package only keeps the
`requestIdleCallback` / `cancelIdleCallback` fallback for environments that
still lack those APIs.
`requestIdleCallback` / `cancelIdleCallback` fallback and the async update
scheduler's `requestAnimationFrame` fallback for environments that still lack
those APIs.

### Migration steps

Expand Down
26 changes: 13 additions & 13 deletions packages/fast-element/SIZES.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,22 @@ Bundle sizes for `@microsoft/fast-element` exports.

| Export | Minified | Gzip | Brotli |
|--------|----------|------|--------|
| CDN Rollup Bundle | 76.36 KB | 22.91 KB | 20.35 KB |
| FASTElement (@microsoft/fast-element/fast-element.js) | 23.73 KB | 7.36 KB | 6.63 KB |
| Updates (@microsoft/fast-element/updates.js) | 473 B | 335 B | 288 B |
| Observable (@microsoft/fast-element/observable.js) | 6.70 KB | 2.50 KB | 2.22 KB |
| observable (@microsoft/fast-element/observable.js) | 6.74 KB | 2.51 KB | 2.23 KB |
| CDN Rollup Bundle | 76.42 KB | 22.91 KB | 20.32 KB |
| FASTElement (@microsoft/fast-element/fast-element.js) | 23.81 KB | 7.37 KB | 6.65 KB |
| Updates (@microsoft/fast-element/updates.js) | 554 B | 361 B | 306 B |
| Observable (@microsoft/fast-element/observable.js) | 6.78 KB | 2.52 KB | 2.24 KB |
| observable (@microsoft/fast-element/observable.js) | 6.82 KB | 2.53 KB | 2.25 KB |
| attr (@microsoft/fast-element/attr.js) | 477 B | 288 B | 244 B |
| children (@microsoft/fast-element/children.js) | 4.81 KB | 1.86 KB | 1.64 KB |
| ref (@microsoft/fast-element/ref.js) | 3.78 KB | 1.52 KB | 1.34 KB |
| slotted (@microsoft/fast-element/slotted.js) | 4.60 KB | 1.79 KB | 1.58 KB |
| volatile (@microsoft/fast-element/volatile.js) | 6.79 KB | 2.53 KB | 2.25 KB |
| volatile (@microsoft/fast-element/volatile.js) | 6.87 KB | 2.54 KB | 2.27 KB |
| when (@microsoft/fast-element/when.js) | 1.82 KB | 712 B | 565 B |
| html (@microsoft/fast-element/html.js) | 25.92 KB | 8.50 KB | 7.61 KB |
| repeat (@microsoft/fast-element/repeat.js) | 29.57 KB | 9.41 KB | 8.48 KB |
| html (@microsoft/fast-element/html.js) | 26.00 KB | 8.51 KB | 7.63 KB |
| repeat (@microsoft/fast-element/repeat.js) | 29.65 KB | 9.43 KB | 8.50 KB |
| css (@microsoft/fast-element/css.js) | 2.43 KB | 1.00 KB | 911 B |
| enableHydration (@microsoft/fast-element/hydration.js) | 43.27 KB | 13.19 KB | 11.88 KB |
| declarativeTemplate (@microsoft/fast-element/declarative.js) | 58.77 KB | 18.45 KB | 16.46 KB |
| ArrayObserver (@microsoft/fast-element/array-observer.js) | 12.51 KB | 4.45 KB | 4.01 KB |
| observerMap (@microsoft/fast-element/observer-map.js) | 20.41 KB | 7.24 KB | 6.52 KB |
| attributeMap (@microsoft/fast-element/attribute-map.js) | 15.78 KB | 5.58 KB | 5.04 KB |
| enableHydration (@microsoft/fast-element/hydration.js) | 43.35 KB | 13.21 KB | 11.90 KB |
| declarativeTemplate (@microsoft/fast-element/declarative.js) | 58.85 KB | 18.47 KB | 16.48 KB |
| ArrayObserver (@microsoft/fast-element/array-observer.js) | 12.59 KB | 4.47 KB | 4.03 KB |
| observerMap (@microsoft/fast-element/observer-map.js) | 20.49 KB | 7.26 KB | 6.54 KB |
| attributeMap (@microsoft/fast-element/attribute-map.js) | 15.86 KB | 5.60 KB | 5.06 KB |
4 changes: 4 additions & 0 deletions packages/fast-element/docs/architecture/updates.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

The `Updates` API is part of the `FAST` global. The updates that are queued include updates to attributes, observables, and observed arrays.

## Scheduling

Asynchronous updates are batched through `requestAnimationFrame` when it is available. In runtimes that do not provide `requestAnimationFrame`, the queue falls back to `setTimeout` so pending updates still flush outside browsers.

## Attributes

An attribute change may be queued when the value of the attribute is updated. This change is triggered by `HTMLElement` lifecycle hook `attributeChangedCallback`. The `Updates` queue is used to try to reflect the attribute with the updated value onto the element using `DOM` APIs.
Expand Down
67 changes: 63 additions & 4 deletions packages/fast-element/src/observation/update-queue.pw.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,69 @@
import { expect, test } from "@playwright/test";
import { Updates } from "./update-queue.js";

const waitMilliseconds = 100;
const maxRecursion = 10;

test.describe("The UpdateQueue", () => {
test.describe("when updating DOM asynchronously", () => {
test("batches tasks with requestAnimationFrame when available", () => {
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
const callbacks: FrameRequestCallback[] = [];

globalThis.requestAnimationFrame = callback => {
callbacks.push(callback);
return callbacks.length;
};

try {
const calls: number[] = [];

Updates.enqueue(() => {
calls.push(0);
});
Updates.enqueue(() => {
calls.push(1);
});
Updates.enqueue(() => {
calls.push(2);
});

expect(callbacks.length).toBe(1);
expect(calls).toEqual([]);

callbacks[0]!(0);

expect(calls).toEqual([0, 1, 2]);
} finally {
globalThis.requestAnimationFrame = originalRequestAnimationFrame;
Updates.process();
}
});

test("falls back when requestAnimationFrame is unavailable", async () => {
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;

globalThis.requestAnimationFrame = undefined as any;

try {
let called = false;

Updates.enqueue(() => {
called = true;
});

const calledBefore = called;

await new Promise(resolve => setTimeout(resolve, waitMilliseconds));

expect(calledBefore).toBe(false);
expect(called).toBe(true);
} finally {
globalThis.requestAnimationFrame = originalRequestAnimationFrame;
Updates.process();
}
});

test("calls task in a future turn", async ({ page }) => {
await page.goto("/");

Expand Down Expand Up @@ -151,12 +210,12 @@ test.describe("The UpdateQueue", () => {
const target = 1060;
const targetList: number[] = [];

for (var i = 0; i < target; i++) {
for (let i = 0; i < target; i++) {
targetList.push(i);
}

const newList: number[] = [];
for (var i = 0; i < target; i++) {
for (let i = 0; i < target; i++) {
(function (i) {
Updates.enqueue(() => {
newList.push(i);
Expand All @@ -182,12 +241,12 @@ test.describe("The UpdateQueue", () => {
const target = 2060;
const targetList: number[] = [];

for (var i = 0; i < target; i++) {
for (let i = 0; i < target; i++) {
targetList.push(i);
}

const newList: number[] = [];
for (var i = 0; i < target; i++) {
for (let i = 0; i < target; i++) {
(function (i) {
Updates.enqueue(() => {
newList.push(i);
Expand Down
11 changes: 9 additions & 2 deletions packages/fast-element/src/observation/update-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,16 @@ export interface UpdateQueue {

const tasks: Callable[] = [];
const pendingErrors: any[] = [];
const rAF = globalThis.requestAnimationFrame;
let updateAsync = true;

function schedule(): void {
if (typeof globalThis.requestAnimationFrame === "function") {
globalThis.requestAnimationFrame(process);
} else {
setTimeout(process, 0);
}
}

function throwFirstError(): void {
if (pendingErrors.length) {
throw pendingErrors.shift();
Expand Down Expand Up @@ -92,7 +99,7 @@ function enqueue(callable: Callable): void {
tasks.push(callable);

if (tasks.length < 2) {
updateAsync ? rAF(process) : process();
updateAsync ? schedule() : process();
}
}

Expand Down
4 changes: 4 additions & 0 deletions sites/website/src/docs/3.x/resources/browser-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,7 @@ The following browsers have native support for the Web Components features used
FAST Element v3 no longer polyfills `globalThis` for older engines. If you need
to support an environment below these minimums, load a `globalThis` polyfill
before importing FAST.

The async update scheduler uses `requestAnimationFrame` in browsers. Runtimes
without `requestAnimationFrame` use a `setTimeout` fallback so queued updates
still flush outside browser environments.