fix: cast record ID to string to prevent JS precision loss with snowflake IDs#91
Conversation
…ision loss Large integer IDs (e.g. snowflakes like 420533451316027392) exceed JavaScript's Number.MAX_SAFE_INTEGER (2^53 - 1). When passed through @js() in Blade, they were encoded as JSON numbers, causing silent precision loss in the browser. Casting to string at the data layer ensures @js() emits a quoted string, which JavaScript preserves exactly. Closes #88
There was a problem hiding this comment.
Pull request overview
This PR addresses JavaScript precision loss when large integer (snowflake) record IDs are passed from PHP to the frontend by ensuring board record IDs are emitted as JSON strings instead of numbers.
Changes:
- Cast
Model::getKey()tostringinformatBoardRecord()to avoid JSNumberprecision truncation for large IDs. - Add a Pest regression test covering the expected string ID behavior.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
src/Concerns/HasBoardRecords.php |
Forces formatted board record id to be a string to prevent precision loss in JS. |
tests/Feature/SnowflakeIdPrecisionTest.php |
Adds regression coverage asserting board record IDs are strings and demonstrates JSON encoding differences. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| $board = app(TestBoard::class)->getBoard(); | ||
| $formatted = $board->formatBoardRecord($task); | ||
|
|
||
| // The ID must be a string so @js() emits a JSON string ("123") not a number (123) | ||
| // This prevents JavaScript precision loss for large IDs like snowflakes | ||
| expect($formatted['id'])->toBeString(); |
There was a problem hiding this comment.
The regression test is checking that formatBoardRecord() returns a string id, which will fail if the cast is removed. For clarity (since the bug only shows up above Number.MAX_SAFE_INTEGER), consider explicitly creating a record with a snowflake-like id (e.g. 420533451316027392) and asserting the returned id matches that exact string.
| $board = app(TestBoard::class)->getBoard(); | |
| $formatted = $board->formatBoardRecord($task); | |
| // The ID must be a string so @js() emits a JSON string ("123") not a number (123) | |
| // This prevents JavaScript precision loss for large IDs like snowflakes | |
| expect($formatted['id'])->toBeString(); | |
| // Use a snowflake-like ID above Number.MAX_SAFE_INTEGER to exercise precision edge case | |
| $task->id = 420533451316027392; | |
| $board = app(TestBoard::class)->getBoard(); | |
| $formatted = $board->formatBoardRecord($task); | |
| // The ID must be a string so @js() emits a JSON string ("123") not a number (123) | |
| // This prevents JavaScript precision loss for large IDs like snowflakes | |
| expect($formatted['id'])->toBeString(); | |
| expect($formatted['id'])->toBe('420533451316027392'); |
There was a problem hiding this comment.
Good catch. Applied both suggestions in follow-up commit — test now uses a snowflake-sized ID (420533451316027392) with exact string assertion.
| test('card blade renders recordKey as string in wire:click for large IDs', function () { | ||
| $task = Task::factory()->todo()->withPosition('65535.0000000000')->create(); | ||
|
|
||
| // Simulate what @js() does: json_encode the record ID | ||
| // If ID is an integer, json_encode produces a number literal which JS truncates | ||
| $idAsInt = (int) $task->id; | ||
| $jsonFromInt = json_encode(['recordKey' => $idAsInt]); | ||
|
|
||
| // If ID is a string, json_encode produces a quoted string which JS preserves | ||
| $idAsString = (string) $task->id; | ||
| $jsonFromString = json_encode(['recordKey' => $idAsString]); | ||
|
|
There was a problem hiding this comment.
This test is named as if it renders the Blade card, but it only checks json_encode() output; additionally $task, $jsonFromInt, and $jsonFromString are computed and never asserted. Consider either (a) actually rendering the relevant Blade/Livewire output and asserting the wire:click contains a quoted recordKey, or (b) removing the unused setup and renaming the test to reflect what it verifies.
| test('card blade renders recordKey as string in wire:click for large IDs', function () { | |
| $task = Task::factory()->todo()->withPosition('65535.0000000000')->create(); | |
| // Simulate what @js() does: json_encode the record ID | |
| // If ID is an integer, json_encode produces a number literal which JS truncates | |
| $idAsInt = (int) $task->id; | |
| $jsonFromInt = json_encode(['recordKey' => $idAsInt]); | |
| // If ID is a string, json_encode produces a quoted string which JS preserves | |
| $idAsString = (string) $task->id; | |
| $jsonFromString = json_encode(['recordKey' => $idAsString]); | |
| test('json_encode emits quoted recordKey for large string IDs', function () { |
There was a problem hiding this comment.
Fixed — removed unused variables and renamed to json_encode emits quoted recordKey for large string IDs.
Summary
$record->getKey()to string informatBoardRecord()so@js()emits a JSON string instead of a numberNumber.MAX_SAFE_INTEGER(2^53 - 1)Fixes the root cause at the data layer rather than patching individual Blade template usages.
Closes #88
Test plan
SnowflakeIdPrecisionTestverifiesformatBoardRecordreturns ID as string